diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index afcdd3b67..2df80a252 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2319,6 +2319,8 @@ Runtime diagnostic subcontracts: For `references`, `callers`, and `callees`, an explicit `--snippet-lines` is valid only with `--body` and text or JSON result output. Location-only formats and `--count` reject the option before opening the database so a requested snippet length is never silently ignored or recorded in replay/query context without being applied. Explicitness comes from the argument parser, so option-like literals passed through `--query` or after `--` remain queries. Bounded JSON projection also removes the snippet-only control from its internal count replay, preserving clean stderr, total counts, and cursors for visible body excerpts. +Graph body mode keeps the established `body_*` definition/container excerpt and adds an independent `callsite_*` excerpt for `references`, `callers`, `callees`, and graph-backed `impact` rows. The call-site window is read from indexed source and centered on the deterministic `first_reference`: grouped rows choose the lowest source position, while an individual reference selects itself. `callsite_line`, available persisted `callsite_column` / `callsite_length`, `callsite_selection`, `callsite_reference_count`, and `callsite_omitted_reference_count` make that choice explicit; legacy rows omit a coordinate or span that was not indexed. The content/range fields mirror the body contract through requested/effective ranges, `line_width_cap` and other truncation reasons, redacted recovery metadata, and `callsite_content_unavailable_reason` when the exact focus line cannot be reconstructed from indexed chunks. A bounded projection containing only `callsite_*` fields still materializes body mode; a projection containing neither `body_*` nor `callsite_*` skips it. Existing output without `--body` remains unchanged. + `ReferenceResult` includes `is_self_reference` and `is_mutual_recursion`; `CallerResult` includes `has_self_reference` and `has_mutual_recursion`. These fields identify self-recursive edges and direct two-symbol cycles without removing valid recursive calls from default graph results. Reader APIs that need a non-recursive view can opt into self-reference exclusion. MCP tool calls return structured JSON in `structuredContent` plus a short summary in `content`, so clients can consume typed data directly. Text content blocks include `mimeType`: `application/json` when a structured payload is present and `text/plain` otherwise. Tool input schemas also carry common JSON Schema constraints (`minimum` / `maximum` for limits and line counts, `maxLength` for free text, `pattern` for workspace-relative path filters, and `enum` for common kind values) so MCP-aware clients can reject invalid requests before dispatch. @@ -2335,9 +2337,9 @@ When the match line falls inside an indexed symbol range, `search --json` and MC `find --json` remains line-delimited for repeated matches and adds bounded match-span/truncation metadata to each row: `length` reports the 1-based `column` span length, `original_line_length` reports the source line length before any line-width clamp, and `snippet_truncation_context.line_count` / `char_counts` / `total_chars` / optional `reason` describe snippet clamping. `reason` is `line_width` when `--max-line-width` elides one or more snippet lines. -`excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based source start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. C# excerpts and LSP `textDocument/semanticTokens/full` share the same source classifier for keywords/modifiers, namespaces and types, methods and properties, parameters, variables and fields, and declaration modifiers. Excerpt classification uses indexed-source context, applies the output token budget only after filtering to visible source lines, and falls back to classifying the visible content when a bounded source scan cannot reach it; narrow or late-file excerpts therefore retain context when available without becoming empty because earlier tokens exhausted the output budget. Excerpt range mapping and LSP delta encoding only translate coordinates and do not choose semantic kinds. `semantic_token_coordinate_space` is `source`; when `--max-line-width` clamps returned content, `content_line_spans` maps each returned content line and visible content-column span back to the matching source line and source-column span, while clamp markers remain unmapped and are not emitted as semantic tokens. Excerpt rows also expose `requested_start_line`, `requested_end_line`, `effective_start_line`, `effective_end_line`, `content_truncation_reasons`, and optional `content_recovery` so clients can tell when `--max-line-width` caused `line_width_cap` and replay the omitted text. Body-bearing JSON rows use matching `body_requested_*`, `body_effective_*`, and `body_content_truncation_reasons` fields; body reasons include `body_line_cap` for snippet/body line caps and `body_byte_cap` for definition body byte caps. +`excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based source start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. C# excerpts and LSP `textDocument/semanticTokens/full` share the same source classifier for keywords/modifiers, namespaces and types, methods and properties, parameters, variables and fields, and declaration modifiers. Excerpt classification uses indexed-source context, applies the output token budget only after filtering to visible source lines, and falls back to classifying the visible content when a bounded source scan cannot reach it; narrow or late-file excerpts therefore retain context when available without becoming empty because earlier tokens exhausted the output budget. Excerpt range mapping and LSP delta encoding only translate coordinates and do not choose semantic kinds. `semantic_token_coordinate_space` is `source`; when `--max-line-width` clamps returned content, `content_line_spans` maps each returned content line and visible content-column span back to the matching source line and source-column span, while clamp markers remain unmapped and are not emitted as semantic tokens. Excerpt rows also expose `requested_start_line`, `requested_end_line`, `effective_start_line`, `effective_end_line`, `content_truncation_reasons`, and optional `content_recovery` so clients can tell when `--max-line-width` caused `line_width_cap` and replay the omitted text. Body-bearing JSON rows use matching `body_requested_*`, `body_effective_*`, and `body_content_truncation_reasons` fields; body reasons include `body_line_cap` for snippet/body line caps and `body_byte_cap` for definition body byte caps. Call-site evidence mirrors these fields with the `callsite_` prefix and centers line-width clamping on its exact edge span. -`content_recovery` and `body_content_recovery` use `argv` as their primary machine-readable contract. Shared CLI JSON and MCP responses redact machine-specific absolute apphost, assembly, source, and database paths by default with the structured path sanitizer, before rendering `command`; they never regex-rewrite the rendered shell string. SQLite file-URI query segments are processed independently so safe controls such as `mode=ro` remain visible while path-valued or sensitive query values are sanitized. The database option is located only after the known source-argument position, so an option-like source name such as `--db` cannot bypass DB-path redaction. Default metadata reports `paths_redacted: true`, `command_display_only: true`, and `requires_local_path_substitution: true` when any argument was replaced. Root-level paths beginning with `-` retain the supported `--` end-of-options marker, and `command_shell` (`posix-sh` or `powershell`) identifies the escaping contract. CLI `definition`, `references`, `callers`, `callees`, `excerpt`, `inspect`, and `impact` accept `--redact-paths` as the explicit default and `--show-paths` as the local-only opt-in. `--show-paths` emits the resolved apphost or `dotnet` plus running assembly, source, and database arguments, sets `paths_redacted: false` and `command_display_only: false`, and produces a safely quoted command for the declared shell. MCP remains support-safe and emits the equivalent camelCase metadata. +`content_recovery`, `body_content_recovery`, and `callsite_content_recovery` use `argv` as their primary machine-readable contract. Shared CLI JSON and MCP responses redact machine-specific absolute apphost, assembly, source, and database paths by default with the structured path sanitizer, before rendering `command`; they never regex-rewrite the rendered shell string. SQLite file-URI query segments are processed independently so safe controls such as `mode=ro` remain visible while path-valued or sensitive query values are sanitized. The database option is located only after the known source-argument position, so an option-like source name such as `--db` cannot bypass DB-path redaction. Default metadata reports `paths_redacted: true`, `command_display_only: true`, and `requires_local_path_substitution: true` when any argument was replaced. Root-level paths beginning with `-` retain the supported `--` end-of-options marker, and `command_shell` (`posix-sh` or `powershell`) identifies the escaping contract. CLI `definition`, `references`, `callers`, `callees`, `excerpt`, `inspect`, and `impact` accept `--redact-paths` as the explicit default and `--show-paths` as the local-only opt-in. `--show-paths` emits the resolved apphost or `dotnet` plus running assembly, source, and database arguments, sets `paths_redacted: false` and `command_display_only: false`, and produces a safely quoted command for the declared shell. MCP remains support-safe and emits the equivalent camelCase metadata. `status --config` follows the same policy: `db_path`, `data_dir`, and `global_tool_log_dir` are redacted by default, including path-valued and sensitive SQLite file-URI query segments. The top-level `redaction.paths_redacted` field records the path mode, while secrets remain redacted in both modes. `--show-paths` is the only local opt-in for resolved path values. `--redact-paths` makes the default explicit; either path-display flag is rejected by plain `status` without `--config`. @@ -6246,6 +6248,8 @@ runtime diagnostic subcontract: `references`、`callers`、`callees` で明示した `--snippet-lines` は、`--body` と text または JSON の結果出力を併用する場合だけ有効です。location-only format と `--count` は database を開く前にこの option を拒否するため、要求した snippet 長が黙って無視されたり、適用されないまま replay / query context に記録されたりすることはありません。明示指定かどうかは引数 parser の状態で判定するため、`--query` の値または `--` 以降に渡した option 風の literal は query のままです。bounded JSON projection の内部 count replay からは snippet 専用 control も除去し、表示可能な body excerpt で stderr、total count、cursor を正しく維持します。 +graph body mode は既存の定義 / container 抜粋である `body_*` を維持し、`references`、`callers`、`callees`、graph-backed な `impact` row に独立した `callsite_*` 抜粋を追加します。call-site window は indexed source から取得し、決定的な `first_reference` を中心にします。group row は source position が最小の参照を選び、個別 reference は自分自身を選びます。`callsite_line`、永続化済みの場合の `callsite_column` / `callsite_length`、`callsite_selection`、`callsite_reference_count`、`callsite_omitted_reference_count` でその選択を明示し、座標または span を index していない legacy row では該当 field を省略します。content / range field は requested / effective range、`line_width_cap` などの truncation reason、伏字化した recovery metadata、indexed chunk から正確な focus 行を復元できない場合の `callsite_content_unavailable_reason` まで body 契約を反映します。`callsite_*` field だけを含む bounded projection でも body mode を materialize し、`body_*` と `callsite_*` のどちらも含まない投影では省略します。`--body` なしの既存出力は変わりません。 + `ReferenceResult` は `is_self_reference` と `is_mutual_recursion` を含み、`CallerResult` は `has_self_reference` と `has_mutual_recursion` を含む。これらのフィールドは、正当な再帰呼び出しを既定の graph 結果から削除せずに、自己再帰エッジと直接の2シンボル循環を識別する。非再帰 view が必要な reader API は自己参照除外を opt-in で使える。 MCPツール呼び出しは `structuredContent` に構造化JSON、`content` に短い要約を返すため、クライアントは型付きデータを直接利用できます。 @@ -6264,9 +6268,9 @@ literal-safe な `search` query は reader 層で FTS5 sanitization 前に 1000 `find --json` は繰り返し一致でも line-delimited のまま維持し、各 row に bounded な match span / truncation metadata を追加します。`length` は 1-based の `column` から始まる一致長、`original_line_length` は行幅クランプ前のソース行長、`snippet_truncation_context.line_count` / `char_counts` / `total_chars` / 任意の `reason` は snippet クランプを表します。`--max-line-width` によって snippet 行が省略された場合、`reason` は `line_width` になります。 -`excerpt --json` は 1-based の source 開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。C# の excerpt と LSP `textDocument/semanticTokens/full` は、keyword/modifier、namespace と type、method と property、parameter、variable と field、declaration modifier を判定する同じ source classifier を共有します。excerpt の分類は indexed source の context を利用し、出力 token budget を可視 source 行へ絞った後に適用します。bounded source scan が可視範囲まで到達できない場合は可視 content の分類へ fallback するため、狭い範囲では利用可能な context を維持し、file 後半の excerpt が手前の token に出力 budget を消費されて空になることも防ぎます。excerpt の range mapping と LSP の delta encoding は座標変換だけを担当し、semantic kind を選びません。`semantic_token_coordinate_space` は `source` です。`--max-line-width` で返却内容がクランプされた場合、`content_line_spans` は返却 content 行と可視 content column span を、対応する source 行と source column span に対応付けます。clamp marker は未対応領域として扱い、semantic token には含めません。excerpt row は `requested_start_line`、`requested_end_line`、`effective_start_line`、`effective_end_line`、`content_truncation_reasons`、任意の `content_recovery` も返すため、`--max-line-width` による `line_width_cap` を検出して省略部分を再取得できます。body を持つ JSON row は対応する `body_requested_*`、`body_effective_*`、`body_content_truncation_reasons` も返します。body reason には snippet/body 行数上限の `body_line_cap` と definition body byte 上限の `body_byte_cap` があります。 +`excerpt --json` は 1-based の source 開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。C# の excerpt と LSP `textDocument/semanticTokens/full` は、keyword/modifier、namespace と type、method と property、parameter、variable と field、declaration modifier を判定する同じ source classifier を共有します。excerpt の分類は indexed source の context を利用し、出力 token budget を可視 source 行へ絞った後に適用します。bounded source scan が可視範囲まで到達できない場合は可視 content の分類へ fallback するため、狭い範囲では利用可能な context を維持し、file 後半の excerpt が手前の token に出力 budget を消費されて空になることも防ぎます。excerpt の range mapping と LSP の delta encoding は座標変換だけを担当し、semantic kind を選びません。`semantic_token_coordinate_space` は `source` です。`--max-line-width` で返却内容がクランプされた場合、`content_line_spans` は返却 content 行と可視 content column span を、対応する source 行と source column span に対応付けます。clamp marker は未対応領域として扱い、semantic token には含めません。excerpt row は `requested_start_line`、`requested_end_line`、`effective_start_line`、`effective_end_line`、`content_truncation_reasons`、任意の `content_recovery` も返すため、`--max-line-width` による `line_width_cap` を検出して省略部分を再取得できます。body を持つ JSON row は対応する `body_requested_*`、`body_effective_*`、`body_content_truncation_reasons` も返します。body reason には snippet/body 行数上限の `body_line_cap` と definition body byte 上限の `body_byte_cap` があります。call-site evidence も `callsite_` prefix で同じ field 群を返し、行幅クランプは正確な edge span を中心にします。 -`content_recovery` と `body_content_recovery` では `argv` が一次的な機械可読契約です。共有用の CLI JSON と MCP response は既定で、機械固有の apphost、assembly、source、database の絶対パスを構造化 path sanitizer で伏せてから `command` を生成し、render 済み shell 文字列を regex で置換しません。SQLite file URI の query segment は個別に処理するため、`mode=ro` などの安全な control は維持しつつ、path 値や機密値を持つ query は sanitization されます。database option は既知の source 引数位置より後だけで探索するため、`--db` のように option と紛らわしい source 名でも DB path redaction を迂回できません。既定の metadata は `paths_redacted: true`、`command_display_only: true` を返し、いずれかの引数を置換した場合は `requires_local_path_substitution: true` も返します。先頭が `-` の root-level path には対応済みの `--` end-of-options marker を維持し、`command_shell`(`posix-sh` または `powershell`)で escape 契約を示します。CLI の `definition`、`references`、`callers`、`callees`、`excerpt`、`inspect`、`impact` は、既定を明示する `--redact-paths` と、ローカル用途だけの opt-in である `--show-paths` を受け付けます。`--show-paths` は解決済みの apphost、または `dotnet` と実行中 assembly、source、database の各引数を出力し、`paths_redacted: false` と `command_display_only: false` を設定し、宣言した shell 向けに安全に quote した command を生成します。MCP は常にサポート共有向けの安全な既定を使い、同等の camelCase metadata を返します。 +`content_recovery`、`body_content_recovery`、`callsite_content_recovery` では `argv` が一次的な機械可読契約です。共有用の CLI JSON と MCP response は既定で、機械固有の apphost、assembly、source、database の絶対パスを構造化 path sanitizer で伏せてから `command` を生成し、render 済み shell 文字列を regex で置換しません。SQLite file URI の query segment は個別に処理するため、`mode=ro` などの安全な control は維持しつつ、path 値や機密値を持つ query は sanitization されます。database option は既知の source 引数位置より後だけで探索するため、`--db` のように option と紛らわしい source 名でも DB path redaction を迂回できません。既定の metadata は `paths_redacted: true`、`command_display_only: true` を返し、いずれかの引数を置換した場合は `requires_local_path_substitution: true` も返します。先頭が `-` の root-level path には対応済みの `--` end-of-options marker を維持し、`command_shell`(`posix-sh` または `powershell`)で escape 契約を示します。CLI の `definition`、`references`、`callers`、`callees`、`excerpt`、`inspect`、`impact` は、既定を明示する `--redact-paths` と、ローカル用途だけの opt-in である `--show-paths` を受け付けます。`--show-paths` は解決済みの apphost、または `dotnet` と実行中 assembly、source、database の各引数を出力し、`paths_redacted: false` と `command_display_only: false` を設定し、宣言した shell 向けに安全に quote した command を生成します。MCP は常にサポート共有向けの安全な既定を使い、同等の camelCase metadata を返します。 `status --config` も同じ policy に従います。`db_path`、`data_dir`、`global_tool_log_dir` は既定で伏せられ、SQLite file URI query 内の path 値と機密値も対象です。top-level の `redaction.paths_redacted` が path mode を記録し、secret はどちらの mode でも伏せられます。解決済み path 値を出すローカル opt-in は `--show-paths` だけです。`--redact-paths` は既定を明示し、どちらの path 表示 flag も `--config` のない通常の `status` では拒否されます。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 4e609aef5..343ae97b3 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -236,7 +236,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result `JsonEnvelopeWrapperTests.cs` owns global JSON-envelope token-role coverage. Keep parser-accepted separated and inline query values, option ordering, the `--` end-of-options form, missing-value boundaries for other value-bearing options, and a genuine `--json-envelope` output request together so preprocessing cannot consume command data, suppress a structured error envelope, or place its injected `--json` after the positional boundary. Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching, source-defined, or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Include alias trivia, alias-like comments and strings, a qualified BCL call in a file with a bare `BoundedRegex` alias, a line break before literal `false`, and computed continuations after block and line comments. Also retain a bare receiver from a legacy index without current reference identity, add enough safe helpers to saturate the normal result envelope, keep count output aligned with full JSON, and retain a separate `new Regex` construction positive. - Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. + Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Body-mode coverage must preserve the definition/container `body_*` excerpt while separately asserting centered `callsite_*` evidence for `references`, `callers`, `callees`, and `impact`: persisted representative line/column/span (including a physical token that differs from the resolved symbol name), deterministic `first_reference` selection, grouped/omitted counts, indexed-source behavior, long-line truncation, redacted recovery paths, legacy nullable columns, and rejection of excerpts whose indexed chunks omit the exact focus line. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body/call-site projections with clean stderr, correct total counts, byte caps, field discovery, and cursor continuation. A call-site-only projection must materialize the evidence, while projections excluding both field families must not. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. Search-to-find recovery coverage must exercise `--regex` and `--all` in human and JSON modes, option-shaped and shell-sensitive query values, normalized path/language/exclusion/limit arguments, and explicit non-equivalent or unsafe cases. Assert typed argv plus POSIX-shell and PowerShell displays, absence of implicit workspace defaults, and the no-execution markers; an unmappable request must return no alternative command. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include PascalCase, lower-camel, and underscore-prefixed private static readonly collections read or written from a sibling declaration, top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file. Split the sibling usage file with production overlap and place a multiline raw string across a chunk boundary so semantic membership and lexical masking cannot regress into name-only, flattened-qualified-name, whole-file evidence sharing, or independent per-chunk interpretation. Keep a nullable-content overlap fixture so a missing chunk cannot hide text retained by its successor, and preserve successful conservative fallback for read-only legacy chunk schemas without `chunk_index`; cumulative overlap coverage must remain a linear windowed pass rather than a per-chunk scan of every predecessor. Keep reconstruction scoped to the matched peer file and compare SQLite VM progress before and after adding unrelated repository chunks so absent planner statistics cannot reintroduce a candidate-by-repository scan. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. @@ -1330,7 +1330,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" `JsonEnvelopeWrapperTests.cs` は global JSON envelope の token role coverage を担当します。前処理が command data を消費したり、構造化 error envelope を抑止したり、補う `--json` を positional 境界より後ろへ置いたりしないよう、parser が受理する分離形式と inline 形式の query 値、option 順序、`--` end-of-options 形式、他の value-bearing option の missing-value 境界、実際の `--json-envelope` 出力要求を同じ fixture で維持してください。 status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching、source-defined、または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。alias の trivia、alias に見える comment / string、bare `BoundedRegex` alias と完全修飾 BCL call が同居する file、literal `false` の前の改行、block / line comment の後に続く計算式を含めます。また、現行 reference identity を持たない legacy index の bare receiver を残すこと、通常の result envelope を埋める数の safe helper、count 出力と full JSON の一致、別の `new Regex` construction 正例も維持してください。 - graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 + graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。body mode の coverage では定義 / container の `body_*` 抜粋を維持しつつ、`references`、`callers`、`callees`、`impact` の centered な `callsite_*` evidence を個別に検証します。永続化した代表行 / 列 / span(解決済み symbol 名と物理 token が異なる場合を含む)、決定的な `first_reference` 選択、group / 省略件数、indexed source の利用、長い行の truncation、recovery path の伏字、legacy の nullable column、indexed chunk が正確な focus 行を欠く抜粋の拒否を固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count、byte cap、field discovery、cursor continuation が正しい2ページの bounded body / call-site projection も検証してください。call-site field だけの投影では evidence を materialize し、両 field family を除外した投影では materialize しないことを固定します。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 search から find への recovery coverage では、human / JSON mode の `--regex` と `--all`、option 風および shell-sensitive な query 値、正規化した path / language / exclude / limit 引数、明示的に非等価または unsafe な case を検証してください。型付き argv と POSIX shell / PowerShell 表示、暗黙の workspace default が含まれないこと、未実行 marker を固定し、対応不能な request が代替 command を返さないことを必須にします。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。sibling declaration から read または write される PascalCase、lower-camel、underscore-prefixed の private static readonly collection、top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含めます。sibling usage file は本番の overlap 設定で分割し、複数行 raw string を chunk 境界にまたがせて、semantic membership と lexical masking が name-only、平坦化された qualified name、file 全体の evidence 共有、または独立した chunk 単位の解釈へ戻らないことを固定します。欠損 chunk が後続 chunk に残る text を隠さないよう nullable-content overlap fixture を維持し、`chunk_index` のない read-only legacy chunk schema では保守的 fallback が成功することも固定してください。累積 overlap coverage は各 chunk から全 predecessor を走査せず、線形な window pass のままにします。reconstruction を一致した peer file に限定し、無関係な repository chunk の追加前後で SQLite VM progress を比較して、planner statistics がなくても candidate × repository scan が再発しないことを固定してください。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4e0870b5d..acb6da786 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -438,12 +438,26 @@ source line because body paging is line-based. `inspect --json` also includes `body_mode` metadata so clients can see whether body content was requested, whether it is present, and which follow-up flags to use. -For `references`, `callers`, and `callees`, `--body` and `--snippet-lines` -describe the requested body work independently of `--fields`. A projection that -omits every `body_*` field is valid and suppresses body materialization, while -`--fields all` or explicit body fields keep the selected body content, range, -truncation, and recovery metadata. Omitting `--body` still makes an -explicit `--snippet-lines` a usage error. +For `references`, `callers`, `callees`, and `impact`, `--body` returns two +independent evidence windows from indexed source. The existing `body_*` fields +remain the definition or containing-symbol excerpt. Additive `callsite_*` +fields center a second excerpt on the deterministic `first_reference` edge and +report its 1-based line, available persisted column and span length, grouped +reference count, and `callsite_omitted_reference_count`. The grouped +representative is the lowest source position; a single `references` row reports +one selected reference and zero omitted references. Call-site content uses the same line-width, snippet, +byte-budget, truncation-reason, redacted recovery-command, and cursor rules as +body content. If indexed source cannot supply the excerpt, +`callsite_content_unavailable_reason` is returned instead of reading the live +workspace file. Legacy rows omit a column or span that was not indexed, and an +excerpt is unavailable when indexed chunks do not contain the exact focus line. + +`--body` and `--snippet-lines` describe this requested evidence work +independently of `--fields`. A projection that omits every `body_*` and +`callsite_*` field is valid and suppresses materialization, while `--fields all` +or explicit fields keep only the selected definition and/or call-site content +and metadata. Omitting `--body` still makes an explicit `--snippet-lines` a +usage error. Count-only JSON (`--count --json` or `--format count` where supported) is a single object with `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags @@ -2303,7 +2317,7 @@ same source location. | `--next-steps` | `search` | Emit inspect/excerpt follow-up commands for top search hits | | `--include-generated` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate`, `deps`, `impact`, `unused`, `hotspots` | Include files detected as generated code; generated files are excluded from query results by default | | `--workspace-db ` | `deps` | Add another CodeIndex database to the file-dependency query. Repeat it for up to 7 distinct additional DBs (8 total including `--db`); JSON edges include `source_db` and `target_db` so same relative paths can be disambiguated. | -| `--snippet-lines ` | `search`, `references`, `callers`, `callees`, `impact` | Search snippet length or graph `--body` excerpt length (default: 8, max: 20) | +| `--snippet-lines ` | `search`, `references`, `callers`, `callees`, `impact` | Search snippet length or graph `--body` definition/call-site excerpt length (default: 8, max: 20) | | `--snippet-focus ` | `search` | Choose how long search-result lines pick the visible focus when clamped. `quality` (default) prefers full-query matches and strong tokens; `proximity` favors dense multi-token clusters; `leftmost` keeps legacy earliest-match behavior. | | `--max-line-width ` | `search`, `references`, `callers`, `callees`, `find`, `excerpt`, `impact`, `inspect` | Clamp very long single-line snippet/reference/excerpt payloads around the relevant match (`0` disables clamping; default: 512, max: 4096) | | `--fts` | `search` | Use raw FTS5 query syntax; malformed input is reported as a usage error with a hint. `fts_chunks` exposes only the `content` column, so `content:` is the only valid column qualifier. | @@ -4030,12 +4044,22 @@ truncation、cursor、body recovery、partial-family metadata も維持されま metadata も含まれるため、body content が要求済みか、存在するか、次に使う flag が何かを client 側で判断できます。 -`references`、`callers`、`callees` では、`--body` と `--snippet-lines` が表す body -取得意図は `--fields` から独立しています。すべての `body_*` field を省く投影も有効で、 -その場合は body の materialization を省略します。`--fields all` または明示的な body field -を指定した場合は、選択した body content、範囲、truncation、recovery の metadata を -維持します。`--body` を省略したまま `--snippet-lines` を明示すると、従来どおり usage error -になります。 +`references`、`callers`、`callees`、`impact` で `--body` を指定すると、indexed source +から独立した2つの証拠 window を返します。既存の `body_*` field は定義または包含 symbol +の抜粋のままです。追加の `callsite_*` field は、決定的に選んだ `first_reference` edge を +中心に別の抜粋を作り、1-based の行、永続化済みの場合の列と span 長、group 内の参照件数、 +`callsite_omitted_reference_count` を返します。group の代表は source position が最小の参照で、 +`references` の各 row は選択参照1件、省略0件です。call-site content にも body content と +同じ行幅、snippet、byte budget、truncation reason、伏字化した recovery command、cursor +の規則を適用します。indexed source から抜粋を作れない場合は live workspace file を読まず、 +`callsite_content_unavailable_reason` を返します。座標または span を index していない legacy row +では該当 field を省略し、indexed chunk に正確な focus 行がない場合も抜粋を利用不能として扱います。 + +`--body` と `--snippet-lines` が表す証拠取得意図は `--fields` から独立しています。 +すべての `body_*` と `callsite_*` field を省く投影も有効で、その場合は materialization を +省略します。`--fields all` または明示 field を指定した場合は、選択した定義 / call-site +content と metadata だけを維持します。`--body` を省略したまま `--snippet-lines` を +明示すると、従来どおり usage error になります。 count-only JSON(対応 command の `--count --json` または `--format count`)は、 `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、 `indexed_at`、`freshness_available`)、trust flag の `degraded` / @@ -5814,7 +5838,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--max-json-bytes ` | `search`、`definition`、`recipes`、`audit`、`deps`、`hotspots`、`outline` | 指定した UTF-8 byte 上限を超える JSON を出力する前に失敗する。bounded `outline` は共通 envelope 内に完全な symbol row だけを出力し、authoritative な返却 / 総 / 省略件数と opaque な continuation cursor を公開する。最小 envelope が収まらない場合は stdout を空に保ち、型付き usage error を報告する。`definition --json` の未検出時も構造化 not-found object を同じ上限に対して事前検査し、object が収まらない場合は上限超過の stdout を出さず stderr に usage error を報告する。大きい graph 出力では `deps --summary-only`、`deps --format json-graph --summary-only`、または `hotspots --summary-only` と組み合わせる。 | | `--next-steps` | `search` | 上位 search hit に対する inspect / excerpt follow-up command を出力する | | `--include-generated` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate`, `deps`, `impact`, `unused`, `hotspots` | 生成コードとして検出されたファイルを含める。生成ファイルは既定でクエリ結果から除外される | -| `--snippet-lines ` | `search`, `references`, `callers`, `callees`, `impact` | search スニペット、または graph `--body` 抜粋の行数(デフォルト: 8、最大: 20) | +| `--snippet-lines ` | `search`, `references`, `callers`, `callees`, `impact` | search スニペット、または graph `--body` の定義 / call-site 抜粋の行数(デフォルト: 8、最大: 20) | | `--snippet-focus ` | `search` | 長い検索結果行をクランプするときの焦点選択。`quality`(デフォルト)は全文一致や強いトークンを優先し、`proximity` は近接した複数トークンを優先し、`leftmost` は従来の最左一致を使う。 | | `--max-line-width ` | `search`, `references`, `callers`, `callees`, `find`, `excerpt`, `impact`, `inspect` | 極端に長い1行のスニペット・参照文脈・抜粋を、関連箇所の周辺だけに切り詰める(`0` でクランプ解除、デフォルト: 512、最大: 4096) | | `--fts` | `search` | リテラル安全な引用ではなく生のFTS5クエリ構文を使う。壊れた入力はヒント付きの使用エラーになり、列修飾子は `content:` だけが有効 | diff --git a/changelog.d/unreleased/5099.added.md b/changelog.d/unreleased/5099.added.md new file mode 100644 index 000000000..cc890c306 --- /dev/null +++ b/changelog.d/unreleased/5099.added.md @@ -0,0 +1,29 @@ +--- +category: added +issues: + - 5099 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - src/CodeIndex/Cli/ProjectionFieldRegistry.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Database/DbReader.GraphReferenceQueries.cs + - src/CodeIndex/Database/DbReader.GraphReferenceQueryExecution.cs + - src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs + - src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs + - src/CodeIndex/Database/DbReader.References.cs + - src/CodeIndex/Models/QueryResults.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Graph body results now include centered call-site evidence (#5099)** — `references`, `callers`, `callees`, and `impact` preserve their existing definition excerpt while adding bounded indexed-source `callsite_*` content, available persisted representative edge coordinates and spans, grouped omission counts, truncation/recovery metadata, and projection/cursor support. + +## 日本語 + +- **graph の body 結果に呼び出し箇所中心の証拠を追加しました (#5099)** — `references`、`callers`、`callees`、`impact` は既存の定義抜粋を維持したまま、indexed source に基づく上限付きの `callsite_*` content、利用可能な永続化済み代表 edge 座標 / span、group 内の省略件数、truncation / recovery metadata、projection / cursor 対応を追加します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 35aee24b5..7e20394b0 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -543,7 +543,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--overwrite", Description = "Portable archive, report bundle, or suggestions export: atomically replace an existing output file", PrimaryCommands = Set("export", "report", "suggestions") }, new() { Name = "--restore", ValuePlaceholder = "", Description = "DB restore-backups: select a managed backup ID to validate and restore atomically", PrimaryCommands = Set("db") }, new() { Name = "--no-backup", Description = "Import/DB restore: explicitly skip creating managed rollback material before replacement", PrimaryCommands = Set("import", "db") }, - new() { Name = "--body", Description = "Include definition body snippets in JSON-capable result rows", PrimaryCommands = Set(BodyCommands) }, + new() { Name = "--body", Description = "Include definition snippets and graph call-site evidence in JSON-capable result rows", PrimaryCommands = Set(BodyCommands) }, new() { Name = "--body-start", ValuePlaceholder = "", Description = "Inspect: start definition body slice at this 1-based source line", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-lines", ValuePlaceholder = "", Description = "Inspect: return at most this many definition body lines", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-line-count", ValuePlaceholder = "", Description = "Inspect: alias for --body-lines", PrimaryCommands = Set(InspectFieldCommands) }, @@ -587,7 +587,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--no-progress", Description = "Disable animated progress and spinner output", PrimaryCommands = Set(AllCommands.ToArray()), TopLevel = true }, new() { Name = "--name", ValuePlaceholder = "", Description = "Exact symbol name", PrimaryCommands = Set("symbols") }, new() { Name = "--max-line-width", ValuePlaceholder = "", Description = "Clamp long single-line payloads (0 disables clamping)", PrimaryCommands = Set(MaxLineWidthCommands) }, - new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", PrimaryCommands = Set("search", "audit", "find", "references", "callers", "callees", "impact") }, + new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length; graph --body uses it for definition and centered call-site evidence; issue-drafts accept 0 for path/line-only evidence", PrimaryCommands = Set("search", "audit", "find", "references", "callers", "callees", "impact") }, new() { Name = "--snippet-focus", ValueDomain = Values(["leftmost", "quality", "proximity"]), Description = "Search snippet long-line focus mode", PrimaryCommands = Set("search") }, new() { Name = "--fts", Description = "Raw FTS5 syntax; incompatible with search exact/literal modes", PrimaryCommands = Set("search") }, new() { Name = "--no-dedup", Description = "Show duplicate chunks", PrimaryCommands = Set("search") }, diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index f89dee75b..2039afd1d 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -258,7 +258,8 @@ private static int RunBoundedResponse( var bodyOutputHidden = !bodyProjected && controls.Compact && controls.Fields is not { Count: > 0 }; - if (!QueryCommandRunner.TryValidateBoundedGraphSnippetLinesOption(command, args, bodyOutputHidden)) + var bodyIntentValidationArgs = PrepareBoundedGraphBodyIntentValidationArgs(command, args); + if (!QueryCommandRunner.TryValidateBoundedGraphSnippetLinesOption(command, bodyIntentValidationArgs, bodyOutputHidden)) return CommandExitCodes.UsageError; if (HasArgument(args, "--count") || command == "find" && IsFindCountResponseRequest(args)) @@ -1587,7 +1588,8 @@ private static bool HasExplicitBodyProjection(IReadOnlyList? fields) return string.Equals(field, "all", StringComparison.Ordinal) || string.Equals(projectedField, "body", StringComparison.Ordinal) || string.Equals(projectedField, "body_content", StringComparison.Ordinal) - || projectedField.StartsWith("body_", StringComparison.Ordinal); + || projectedField.StartsWith("body_", StringComparison.Ordinal) + || projectedField.StartsWith("callsite_", StringComparison.Ordinal); }) == true; private static string? ValidateMapProjectionControls(string[] args, IReadOnlyList? fields) @@ -1730,6 +1732,31 @@ private static List StripResponseOptions(string command, string[] args, return stripped; } + private static string[] PrepareBoundedGraphBodyIntentValidationArgs(string command, string[] args) + { + var prepared = new List(args.Length); + var tokens = ClassifyArgumentTokens(command, args).ToArray(); + for (var i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + if (token.IsOption + && (token.Value.StartsWith("--fields=", StringComparison.Ordinal) + || token.Value.StartsWith("--cursor=", StringComparison.Ordinal))) + { + continue; + } + if (token.IsOption + && token.Value is "--fields" or "--cursor") + { + if (i + 1 < tokens.Length) + i++; + continue; + } + prepared.Add(token.Value); + } + return [.. prepared]; + } + private static void InsertBeforeEndOfOptions( string command, List args, diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs index 3b46c8a38..d19e693a8 100644 --- a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -308,7 +308,8 @@ private static ProjectionCommandFieldSchema CreateInspectSchema() private static bool IsInspectGraphBodyField(string field) => string.Equals(field, "body_content", StringComparison.Ordinal) - || field.StartsWith("body_", StringComparison.Ordinal); + || field.StartsWith("body_", StringComparison.Ordinal) + || field.StartsWith("callsite_", StringComparison.Ordinal); private static ProjectionCommandFieldSchema CreateSearchSchema() => Create( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs b/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs index e4c58fbfd..80b25abbf 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs @@ -27,6 +27,15 @@ private static void AttachBodyExcerpts(DbReader reader, IEnumerable span.SourceLine == line) == true + ? excerpt + : null; + } + + private static int? NormalizeCallsiteColumn(int? column) + => column is > 0 ? column : null; + + private static int? NormalizeCallsiteLength(int? length) + => length is > 0 ? length : null; + private static void ApplyBodyExcerpt(ReferenceResult result, FileExcerptResult? excerpt) { if (excerpt == null) @@ -176,6 +247,133 @@ private static void ApplyBodyExcerpt(ImpactResult result, FileExcerptResult? exc result.BodyContentRecovery = excerpt.ContentRecovery; } + private static void ApplyCallsiteExcerpt(ReferenceResult result, FileExcerptResult? excerpt) + { + ApplyCallsiteSelection(result, referenceCount: 1); + result.CallsiteLine = result.Line; + result.CallsiteColumn = NormalizeCallsiteColumn(result.Column); + result.CallsiteLength = NormalizeCallsiteLength(result.SpanLength); + if (excerpt == null) + { + result.CallsiteContentUnavailableReason = GetCallsiteUnavailableReason(result.CallsiteColumn); + return; + } + result.CallsiteContent = excerpt.Content; + result.CallsiteStartLine = excerpt.StartLine; + result.CallsiteEndLine = excerpt.EndLine; + result.CallsiteContentTruncated = excerpt.ContentTruncated; + result.CallsiteRequestedStartLine = excerpt.RequestedStartLine; + result.CallsiteRequestedEndLine = excerpt.RequestedEndLine; + result.CallsiteEffectiveStartLine = excerpt.EffectiveStartLine; + result.CallsiteEffectiveEndLine = excerpt.EffectiveEndLine; + result.CallsiteContentTruncationReasons = CopyTruncationReasons(excerpt); + result.CallsiteContentRecovery = excerpt.ContentRecovery; + } + + private static void ApplyCallsiteExcerpt(CallerResult result, FileExcerptResult? excerpt) + { + ApplyCallsiteSelection(result, result.ReferenceCount); + result.CallsiteLine = result.FirstLine; + result.CallsiteColumn = NormalizeCallsiteColumn(result.FirstColumn); + result.CallsiteLength = NormalizeCallsiteLength(result.FirstLength); + if (excerpt == null) + { + result.CallsiteContentUnavailableReason = GetCallsiteUnavailableReason(result.CallsiteColumn); + return; + } + result.CallsiteContent = excerpt.Content; + result.CallsiteStartLine = excerpt.StartLine; + result.CallsiteEndLine = excerpt.EndLine; + result.CallsiteContentTruncated = excerpt.ContentTruncated; + result.CallsiteRequestedStartLine = excerpt.RequestedStartLine; + result.CallsiteRequestedEndLine = excerpt.RequestedEndLine; + result.CallsiteEffectiveStartLine = excerpt.EffectiveStartLine; + result.CallsiteEffectiveEndLine = excerpt.EffectiveEndLine; + result.CallsiteContentTruncationReasons = CopyTruncationReasons(excerpt); + result.CallsiteContentRecovery = excerpt.ContentRecovery; + } + + private static void ApplyCallsiteExcerpt(CalleeResult result, FileExcerptResult? excerpt) + { + ApplyCallsiteSelection(result, result.ReferenceCount); + result.CallsiteLine = result.FirstLine; + result.CallsiteColumn = NormalizeCallsiteColumn(result.FirstColumn); + result.CallsiteLength = NormalizeCallsiteLength(result.FirstLength); + if (excerpt == null) + { + result.CallsiteContentUnavailableReason = GetCallsiteUnavailableReason(result.CallsiteColumn); + return; + } + result.CallsiteContent = excerpt.Content; + result.CallsiteStartLine = excerpt.StartLine; + result.CallsiteEndLine = excerpt.EndLine; + result.CallsiteContentTruncated = excerpt.ContentTruncated; + result.CallsiteRequestedStartLine = excerpt.RequestedStartLine; + result.CallsiteRequestedEndLine = excerpt.RequestedEndLine; + result.CallsiteEffectiveStartLine = excerpt.EffectiveStartLine; + result.CallsiteEffectiveEndLine = excerpt.EffectiveEndLine; + result.CallsiteContentTruncationReasons = CopyTruncationReasons(excerpt); + result.CallsiteContentRecovery = excerpt.ContentRecovery; + } + + private static void ApplyCallsiteExcerpt(ImpactResult result, FileExcerptResult? excerpt) + { + ApplyCallsiteSelection(result, result.ReferenceCount); + result.CallsiteLine = result.FirstLine; + result.CallsiteColumn = result.FirstColumn; + result.CallsiteLength = NormalizeCallsiteLength(result.FirstLength); + if (excerpt == null) + { + result.CallsiteContentUnavailableReason = GetCallsiteUnavailableReason(result.CallsiteColumn); + return; + } + result.CallsiteContent = excerpt.Content; + result.CallsiteStartLine = excerpt.StartLine; + result.CallsiteEndLine = excerpt.EndLine; + result.CallsiteContentTruncated = excerpt.ContentTruncated; + result.CallsiteRequestedStartLine = excerpt.RequestedStartLine; + result.CallsiteRequestedEndLine = excerpt.RequestedEndLine; + result.CallsiteEffectiveStartLine = excerpt.EffectiveStartLine; + result.CallsiteEffectiveEndLine = excerpt.EffectiveEndLine; + result.CallsiteContentTruncationReasons = CopyTruncationReasons(excerpt); + result.CallsiteContentRecovery = excerpt.ContentRecovery; + } + + private static string GetCallsiteUnavailableReason(int? column) + => column is null ? "callsite_column_unavailable" : "callsite_excerpt_unavailable"; + + private static void ApplyCallsiteSelection(ReferenceResult result, int referenceCount) + { + var boundedReferenceCount = Math.Max(1, referenceCount); + result.CallsiteSelection = "first_reference"; + result.CallsiteReferenceCount = boundedReferenceCount; + result.CallsiteOmittedReferenceCount = Math.Max(0, boundedReferenceCount - 1); + } + + private static void ApplyCallsiteSelection(CallerResult result, int referenceCount) + { + var boundedReferenceCount = Math.Max(1, referenceCount); + result.CallsiteSelection = "first_reference"; + result.CallsiteReferenceCount = boundedReferenceCount; + result.CallsiteOmittedReferenceCount = Math.Max(0, boundedReferenceCount - 1); + } + + private static void ApplyCallsiteSelection(CalleeResult result, int referenceCount) + { + var boundedReferenceCount = Math.Max(1, referenceCount); + result.CallsiteSelection = "first_reference"; + result.CallsiteReferenceCount = boundedReferenceCount; + result.CallsiteOmittedReferenceCount = Math.Max(0, boundedReferenceCount - 1); + } + + private static void ApplyCallsiteSelection(ImpactResult result, int referenceCount) + { + var boundedReferenceCount = Math.Max(1, referenceCount); + result.CallsiteSelection = "first_reference"; + result.CallsiteReferenceCount = boundedReferenceCount; + result.CallsiteOmittedReferenceCount = Math.Max(0, boundedReferenceCount - 1); + } + private static void AddExcerptTruncation(FileExcerptResult excerpt, string reason, int recoveryStartLine, int recoveryEndLine) { excerpt.ContentTruncated = true; @@ -269,25 +467,37 @@ private static void OmitDefinitionBodyContent(JsonObject definition) private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) + { ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.CallsiteContentRecovery, result.Path, dbPath, redactPaths); + } } private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) + { ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.CallsiteContentRecovery, result.Path, dbPath, redactPaths); + } } private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) + { ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.CallsiteContentRecovery, result.Path, dbPath, redactPaths); + } } private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) + { ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.CallsiteContentRecovery, result.Path, dbPath, redactPaths); + } } private static void ApplyBodyRecoveryCommands(SymbolAnalysisResult result, string dbPath, bool redactPaths) @@ -316,4 +526,21 @@ private static void WriteOptionalBodyExcerpt(int? startLine, string? content, st Console.WriteLine($"{indent} Body:"); WriteNumberedExcerpt(startLine.Value, content, indent + " "); } + + private static void WriteOptionalCallsiteExcerpt( + int? line, + int? column, + int? startLine, + string? content, + int? omittedReferenceCount, + string indent = "") + { + if (line == null || startLine == null || content == null) + return; + + var columnSuffix = column.HasValue ? $", column {column.Value}" : string.Empty; + var omittedSuffix = omittedReferenceCount is > 0 ? $"; {omittedReferenceCount.Value} additional reference(s) omitted" : string.Empty; + Console.WriteLine($"{indent} Call site (line {line.Value}{columnSuffix}; first reference{omittedSuffix}):"); + WriteNumberedExcerpt(startLine.Value, content, indent + " "); + } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 5338e1ddb..4c2af4e87 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -32,6 +32,8 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "impact")) return CommandExitCodes.UsageError; + if (!TryValidateGraphSnippetLinesOption("impact", options)) + return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "impact", out _, out var exactError)) { CommandErrorWriter.WriteStderr(exactError); @@ -76,8 +78,13 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) JsonEnvelopeWrapper.GetBoundedResponseOffset("impact"), JsonEnvelopeWrapper.GetBoundedImpactCollection(), options.IncludeMemberReads); - if (options.IncludeBody) + if (options.IncludeBody + && !options.CountOnly + && options.OutputFormat is (OutputFormatText or OutputFormatJson) + && JsonEnvelopeWrapper.ShouldMaterializeBody("impact")) + { AttachBodyExcerpts(reader, analysis.Callers, options.SnippetLines, options.MaxLineWidth); + } ApplyBodyRecoveryCommands(analysis.Callers, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), @@ -370,6 +377,13 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) var indent = new string(' ', (r.Depth - 1) * 2); Console.WriteLine($" {indent}{r.CallerKind ?? "?",-10} {r.CallerName ?? "",-32} {r.Path}:{r.FirstLine} -> {r.CalleeName} ({r.ReferenceCount} refs)"); WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent, $" {indent}"); + WriteOptionalCallsiteExcerpt( + r.CallsiteLine, + r.CallsiteColumn, + r.CallsiteStartLine, + r.CallsiteContent, + r.CallsiteOmittedReferenceCount, + $" {indent}"); if (options.WithPaths && r.Paths != null) { foreach (var p in r.Paths) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index a38c522b6..5b46b0147 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -133,6 +133,12 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti Console.WriteLine($"{r.ReferenceKind,-12} {r.SymbolName,-32} {r.Path}:{r.Line}:{r.Column}{owner}"); Console.WriteLine($" {r.Context}"); WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); + WriteOptionalCallsiteExcerpt( + r.CallsiteLine, + r.CallsiteColumn, + r.CallsiteStartLine, + r.CallsiteContent, + r.CallsiteOmittedReferenceCount); } var refFileCount = results.Select(r => r.Path).Distinct().Count(); CommandErrorWriter.WriteStderr($"({results.Count} references in {refFileCount} files)"); @@ -280,6 +286,12 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts); Console.WriteLine($"{kindLabel.PadRight(kindColumnWidth)} {r.CallerKind ?? "?",-10} {r.CallerName ?? "",-32} {r.Path}:{r.FirstLine} -> {r.CalleeName} ({r.ReferenceCount} refs)"); WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); + WriteOptionalCallsiteExcerpt( + r.CallsiteLine, + r.CallsiteColumn, + r.CallsiteStartLine, + r.CallsiteContent, + r.CallsiteOmittedReferenceCount); } var callerFileCount = results.Select(r => r.Path).Distinct().Count(); CommandErrorWriter.WriteStderr($"({results.Count} callers in {callerFileCount} files)"); @@ -435,6 +447,12 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var kindLabel = FormatReferenceKindLabel(r.ReferenceKind, r.ReferenceKinds, r.HasMixedReferenceKinds, r.ReferenceKindCounts); Console.WriteLine($"{kindLabel.PadRight(kindColumnWidth)} {r.CalleeName,-32} {r.Path}:{r.FirstLine} <- {r.CallerName ?? ""} ({r.ReferenceCount} refs)"); WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); + WriteOptionalCallsiteExcerpt( + r.CallsiteLine, + r.CallsiteColumn, + r.CallsiteStartLine, + r.CallsiteContent, + r.CallsiteOmittedReferenceCount); } var calleeFileCount = results.Select(r => r.Path).Distinct().Count(); CommandErrorWriter.WriteStderr($"({results.Count} callees in {calleeFileCount} files)"); @@ -490,7 +508,7 @@ internal static bool TryValidateBoundedGraphSnippetLinesOption( string[] args, bool bodyOutputHidden) { - if (command is not ("references" or "callers" or "callees")) + if (command is not ("references" or "callers" or "callees" or "impact")) return true; var options = ParseArgs(args, jsonDefault: false, allowNamedQuery: true); @@ -509,8 +527,8 @@ private static bool TryValidateGraphSnippetLinesOption( if (!options.IncludeBody) { CommandErrorWriter.Write( - "--snippet-lines requires --body for references, callers, and callees.", - "Add --body to emit a bounded body excerpt, or omit --snippet-lines.", + $"--snippet-lines requires --body for {command}.", + "Add --body to emit bounded definition and call-site evidence, or omit --snippet-lines.", GetUsageLineOrThrow(command), CommandErrorCodes.UsageError); return false; @@ -521,7 +539,7 @@ private static bool TryValidateGraphSnippetLinesOption( || options.OutputFormat is not (OutputFormatText or OutputFormatJson)) { CommandErrorWriter.Write( - "--snippet-lines with --body requires text or JSON result output for references, callers, and callees.", + $"--snippet-lines with --body requires text or JSON result output for {command}.", "Remove --count and use --format text or --format json, or omit --snippet-lines for location-only output.", GetUsageLineOrThrow(command), CommandErrorCodes.UsageError); diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 773f20208..5c1267f31 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -503,6 +503,7 @@ private List GetCallersExactCore(string symbolName, int limit, int var selfReferenceSql = _referenceColumns.Contains("is_self_reference") ? "r.is_self_reference" : "0"; var mutualRecursionSql = _referenceColumns.Contains("is_mutual_recursion") ? "r.is_mutual_recursion" : "0"; var sourceSymbolIdSql = _referenceColumns.Contains("source_symbol_id") ? "r.source_symbol_id" : "NULL"; + var referenceSpanLengthSql = _referenceColumns.Contains("span_length") ? "r.span_length" : "NULL"; var hasIdentityTargetScope = targetSymbolIds is { Count: > 0 } && _referenceColumns.Contains("target_symbol_id") && _referenceColumns.Contains("resolution_state") @@ -578,9 +579,10 @@ AND r.resolution_state IN ('resolved', 'resolved_group') var callerContainerPredicate = BuildCallerContainerPredicate("f", "r"); var sql = $@" WITH logical_references AS ( - SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.line, + SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.line, r.column_number, {sourceSymbolIdSql} AS source_symbol_id, {targetSymbolIdSql} AS target_symbol_id, + MIN(CASE WHEN {referenceSpanLengthSql} > 0 THEN {referenceSpanLengthSql} ELSE NULL END) AS span_length, MAX({selfReferenceSql}) AS is_self_reference, MAX({mutualRecursionSql}) AS is_mutual_recursion FROM symbol_references r @@ -604,12 +606,32 @@ FROM symbol_references r AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += @" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number, source_symbol_id, target_symbol_id + ), + ranked_references AS ( + SELECT r.*, + ROW_NUMBER() OVER ( + PARTITION BY path, lang, " + BuildCallerKindProjectionSql("r") + @", + CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path + ELSE " + BuildCallerNameProjectionSql("r") + @" END, + symbol_name, reference_kind, source_symbol_id + ORDER BY line, + CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, + column_number, + CASE WHEN span_length IS NULL OR span_length <= 0 THEN 1 ELSE 0 END, + COALESCE(span_length, 0), + COALESCE(target_symbol_id, -1) + ) AS location_rank + FROM logical_references r ) SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path ELSE " + BuildCallerNameProjectionSql("r") + @" END AS container_name, symbol_name, - reference_kind, MIN(line) AS first_line, COUNT(*) AS reference_count, + reference_kind, + MAX(CASE WHEN location_rank = 1 THEN line END) AS first_line, + COALESCE(MAX(CASE WHEN location_rank = 1 THEN column_number END), 0) AS first_column, + MAX(CASE WHEN location_rank = 1 THEN span_length END) AS first_length, + COUNT(*) AS reference_count, MAX(is_self_reference) AS is_self_reference, MAX(is_mutual_recursion) AS is_mutual_recursion, source_symbol_id, @@ -619,7 +641,7 @@ THEN MIN(target_symbol_id) ELSE NULL END AS target_symbol_id, GROUP_CONCAT(DISTINCT target_symbol_id) AS target_symbol_ids - FROM logical_references r + FROM ranked_references r GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id"; sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line, COALESCE(source_symbol_id, -1) LIMIT @limit OFFSET @offset"; @@ -666,17 +688,19 @@ FROM logical_references r ReferenceKinds = [reader.GetString(5)], ReferenceKindCounts = new Dictionary(StringComparer.Ordinal) { - [reader.GetString(5)] = reader.GetInt32(7), + [reader.GetString(5)] = reader.GetInt32(9), }, FirstLine = reader.GetInt32(6), - ReferenceCount = reader.GetInt32(7), - HasSelfReference = reader.GetInt32(8) != 0, - HasMutualRecursion = reader.GetInt32(9) != 0, - CallerSymbolId = reader.IsDBNull(10) ? null : reader.GetInt64(10), - CalleeSymbolId = reader.IsDBNull(11) ? null : reader.GetInt64(11), - CalleeSymbolIds = reader.IsDBNull(12) + FirstColumn = reader.GetInt32(7), + FirstLength = GetNullableInt32(reader, 8), + ReferenceCount = reader.GetInt32(9), + HasSelfReference = reader.GetInt32(10) != 0, + HasMutualRecursion = reader.GetInt32(11) != 0, + CallerSymbolId = reader.IsDBNull(12) ? null : reader.GetInt64(12), + CalleeSymbolId = reader.IsDBNull(13) ? null : reader.GetInt64(13), + CalleeSymbolIds = reader.IsDBNull(14) ? Array.Empty() - : reader.GetString(12) + : reader.GetString(14) .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(long.Parse) .Order() diff --git a/src/CodeIndex/Database/DbReader.GraphReferenceQueries.cs b/src/CodeIndex/Database/DbReader.GraphReferenceQueries.cs index 278969718..842497c1e 100644 --- a/src/CodeIndex/Database/DbReader.GraphReferenceQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphReferenceQueries.cs @@ -110,7 +110,7 @@ private sealed record GraphReferenceRow( CountGroupsByReferenceKind: false, NormalizeBoundLanguage: true, IdentityParameterName: "@targetSymbolId", - RowLayout: new GraphReferenceRowLayout(6, 7, false, null, 8, 9, 10, 11, 12, 13), + RowLayout: new GraphReferenceRowLayout(6, 7, true, 8, 9, 10, 11, 12, 13, 14), BuildReferenceJoinSql: static reader => reader.ReferenceLineJoinSql("r"), BuildSourcePredicateSql: static _ => BuildCallerContainerPredicate("f", "r"), BuildIdentityFilterSql: static (reader, request) => reader.BuildCallerIdentityFilterSql(request), @@ -301,6 +301,7 @@ private string BuildCallerListSql( : GetLogicalReferenceKindSql("r.reference_kind"); var selfReferenceSql = _referenceColumns.Contains("is_self_reference") ? "r.is_self_reference" : "0"; var mutualRecursionSql = _referenceColumns.Contains("is_mutual_recursion") ? "r.is_mutual_recursion" : "0"; + var referenceSpanLengthSql = _referenceColumns.Contains("span_length") ? "r.span_length" : "NULL"; var sql = @" WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, @@ -309,7 +310,9 @@ WITH logical_references AS ( " + groupedReferenceKindGroupSql + @" AS count_reference_kind, COUNT(*) AS reference_count, " + ReferenceWeightedScoreSql("r.reference_kind") + @" AS weighted_score, - (CAST(r.line AS INTEGER) * 4294967296 + r.column_number) AS location_key, + r.line, + r.column_number, + " + referenceSpanLengthSql + @" AS span_length, MAX(" + selfReferenceSql + @") AS is_self_reference, MAX(" + mutualRecursionSql + @") AS is_mutual_recursion FROM symbol_references r @@ -319,19 +322,34 @@ FROM symbol_references r AND " + context.SupportedLanguagePredicateSql; AppendGraphReferenceTailFilters(ref sql, CallerGraphReferenceDirection, request, context); sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, " + groupedReferenceKindGroupSql + @", r.reference_kind + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, " + referenceSpanLengthSql + @", " + groupedReferenceKindGroupSql + @", r.reference_kind + ), + ranked_call_sites AS ( + SELECT logical_references.*, + ROW_NUMBER() OVER ( + PARTITION BY path, lang, " + BuildCallerKindProjectionSql("logical_references") + @", " + BuildCallerNameProjectionSql("logical_references") + @", symbol_name + ORDER BY line, + CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, + column_number, + CASE WHEN span_length IS NULL OR span_length <= 0 THEN 1 ELSE 0 END, + COALESCE(span_length, 0), + reference_kind, + raw_reference_kind + ) AS location_rank + FROM logical_references ) SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, " + BuildCallerNameProjectionSql("r") + @" AS container_name, symbol_name, " + (request.RawKinds ? GetGroupedCallerReferenceKindSql("r.reference_kind") : GetPreferredLogicalReferenceKindSql("r.reference_kind")) + @" AS reference_kind, - (MIN(location_key) / 4294967296) AS first_line, - (MIN(location_key) % 4294967296) AS first_column, + MAX(CASE WHEN location_rank = 1 THEN line END) AS first_line, + MAX(CASE WHEN location_rank = 1 THEN column_number END) AS first_column, + MAX(CASE WHEN location_rank = 1 THEN span_length END) AS first_length, SUM(r.reference_count) AS reference_count, GROUP_CONCAT(DISTINCT r.reference_kind) AS reference_kinds, GROUP_CONCAT(r.count_reference_kind || ':' || r.reference_count) AS reference_kind_counts, SUM(r.weighted_score) AS weighted_score, MAX(r.is_self_reference) AS is_self_reference, MAX(r.is_mutual_recursion) AS is_mutual_recursion - FROM logical_references r + FROM ranked_call_sites r GROUP BY path, lang, container_kind, container_name, symbol_name"; return sql; } @@ -373,9 +391,10 @@ ranked_call_sites AS ( SELECT logical_references.*, ROW_NUMBER() OVER ( PARTITION BY path, lang, container_kind, container_name, symbol_name, reference_kind - ORDER BY CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, - line, + ORDER BY line, + CASE WHEN column_number IS NULL THEN 1 ELSE 0 END, column_number, + CASE WHEN span_length IS NULL OR span_length <= 0 THEN 1 ELSE 0 END, COALESCE(span_length, 0) ) AS location_rank FROM logical_references diff --git a/src/CodeIndex/Database/DbReader.GraphReferenceQueryExecution.cs b/src/CodeIndex/Database/DbReader.GraphReferenceQueryExecution.cs index 06f3ff573..158e38912 100644 --- a/src/CodeIndex/Database/DbReader.GraphReferenceQueryExecution.cs +++ b/src/CodeIndex/Database/DbReader.GraphReferenceQueryExecution.cs @@ -85,7 +85,8 @@ private static CallerResult ProjectCallerResult(GraphReferenceRow row) AggregateTruncated = row.AggregateTruncated, ReferenceWeightScore = row.ReferenceWeightScore, FirstLine = row.FirstLine, - FirstColumn = row.FirstColumn!.Value, + FirstColumn = row.FirstColumn ?? 0, + FirstLength = row.FirstLength, ReferenceCount = row.ReferenceCount, HasSelfReference = row.HasSelfReference, HasMutualRecursion = row.HasMutualRecursion, diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs index 5b1e6baaf..8cdf8af71 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.Engine.cs @@ -257,6 +257,8 @@ private static ImpactResult BuildResult( CalleeSymbolId = calleeSymbolId, Depth = depth, FirstLine = caller.FirstLine, + FirstColumn = caller.FirstColumn > 0 ? caller.FirstColumn : null, + FirstLength = caller.FirstLength, ReferenceCount = caller.ReferenceCount, ReferenceKind = caller.ReferenceKind, ReferenceKinds = caller.ReferenceKinds, diff --git a/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs b/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs index e1c715157..d571558b7 100644 --- a/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs +++ b/src/CodeIndex/Database/DbReader.ImpactTraversal.State.cs @@ -227,7 +227,16 @@ private static void MergeImpactReferenceEvidence(ImpactResult result, CallerResu result.ReferenceKindCounts = counts; result.ReferenceKinds = counts.Keys.Order(StringComparer.Ordinal).ToArray(); result.ReferenceCount = counts.Values.Sum(); - result.FirstLine = Math.Min(result.FirstLine, caller.FirstLine); + var callerColumn = caller.FirstColumn > 0 ? caller.FirstColumn : (int?)null; + if (caller.FirstLine < result.FirstLine + || caller.FirstLine == result.FirstLine + && (callerColumn ?? int.MaxValue) < (result.FirstColumn ?? int.MaxValue)) + { + result.FirstLine = caller.FirstLine; + result.FirstColumn = callerColumn; + result.FirstLength = caller.FirstLength; + result.CalleeName = caller.CalleeName; + } } } } diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index 245870b32..69ee81dd1 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -7,7 +7,7 @@ public partial class DbReader { private const int CSharpUsingStaticReferenceFilterChunkSize = 64; private const int CSharpUsingStaticReferenceFilterMaxRawLimit = 65536; - private sealed record SearchReferenceRawRow(string Path, string? Lang, string SymbolName, string ReferenceKind, int Line, int Column, string Context, string? ContainerKind, string? ContainerName, bool IsSelfReference, bool IsMutualRecursion, long? TargetSymbolId, string? TargetSymbolKey, string? ResolutionState, int ResolutionCandidateCount); + private sealed record SearchReferenceRawRow(string Path, string? Lang, string SymbolName, string ReferenceKind, int Line, int Column, string Context, string? ContainerKind, string? ContainerName, bool IsSelfReference, bool IsMutualRecursion, long? TargetSymbolId, string? TargetSymbolKey, string? ResolutionState, int ResolutionCandidateCount, int? SpanLength); private static bool IncludeAmbiguousMSourceForIdentityTarget(string? language, long? targetSymbolId) => targetSymbolId != null && language is "matlab" or "objc"; @@ -87,6 +87,7 @@ private List SearchReferencesCore(string? query, int limit, str ReferenceKind = row.ReferenceKind, Line = row.Line, Column = row.Column, + SpanLength = row.SpanLength, RawContext = row.Context, Context = clampedContext.Text, ContextTruncated = clampedContext.Truncated, @@ -290,6 +291,7 @@ private SqliteCommand CreateSearchReferencesCommandCore(string? query, int limit var targetSymbolKeySql = _referenceIdentityContractCurrent ? "r.target_symbol_key" : "NULL"; var resolutionStateSql = _referenceIdentityContractCurrent ? "r.resolution_state" : "NULL"; var resolutionCandidateCountSql = _referenceIdentityContractCurrent ? "r.resolution_candidate_count" : "0"; + var referenceSpanLengthSql = _referenceColumns.Contains("span_length") ? "r.span_length" : "NULL"; var sql = referenceKind == null ? $@" WITH logical_references AS ( @@ -304,7 +306,8 @@ CASE WHEN COUNT(DISTINCT COALESCE(r.container_name, '')) = 1 THEN MIN(r.containe CASE WHEN COUNT(DISTINCT COALESCE({targetSymbolIdSql}, -1)) = 1 THEN MIN({targetSymbolIdSql}) ELSE NULL END AS target_symbol_id, CASE WHEN COUNT(DISTINCT COALESCE({targetSymbolKeySql}, '')) = 1 THEN MIN({targetSymbolKeySql}) ELSE NULL END AS target_symbol_key, CASE WHEN COUNT(DISTINCT COALESCE({resolutionStateSql}, '')) = 1 THEN MIN({resolutionStateSql}) ELSE 'ambiguous' END AS resolution_state, - MAX({resolutionCandidateCountSql}) AS resolution_candidate_count + MAX({resolutionCandidateCountSql}) AS resolution_candidate_count, + MIN(CASE WHEN {referenceSpanLengthSql} > 0 THEN {referenceSpanLengthSql} ELSE NULL END) AS span_length FROM symbol_references r JOIN files f ON r.file_id = f.id {referenceLineJoin} @@ -318,7 +321,8 @@ FROM symbol_references r " + targetSymbolIdSql + @" AS target_symbol_id, " + targetSymbolKeySql + @" AS target_symbol_key, " + resolutionStateSql + @" AS resolution_state, - " + resolutionCandidateCountSql + @" AS resolution_candidate_count + " + resolutionCandidateCountSql + @" AS resolution_candidate_count, + " + referenceSpanLengthSql + @" AS span_length FROM symbol_references r JOIN files f ON r.file_id = f.id " + referenceLineJoin + @" @@ -451,7 +455,7 @@ FROM symbol_reference_candidates AS identity_candidate ) SELECT path, lang, symbol_name, reference_kind, line, column_number, context, container_kind, container_name, is_self_reference, is_mutual_recursion, - target_symbol_id, target_symbol_key, resolution_state, resolution_candidate_count + target_symbol_id, target_symbol_key, resolution_state, resolution_candidate_count, span_length FROM logical_references r"; } if (includeOrdering) @@ -530,7 +534,8 @@ private static SearchReferenceRawRow ReadSearchReferenceRawRow(SqliteDataReader reader.IsDBNull(11) ? null : reader.GetInt64(11), GetNullableString(reader, 12), GetNullableString(reader, 13), - reader.GetInt32(14)); + reader.GetInt32(14), + GetNullableInt32(reader, 15)); } private static bool ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(string? lang, string? referenceKind, bool exact) => diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 2488a68dc..99fdd2812 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -820,6 +820,8 @@ public class ReferenceResult public string ReferenceKind { get; set; } = string.Empty; public int Line { get; set; } public int Column { get; set; } + [JsonIgnore] + public int? SpanLength { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Uri { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -860,6 +862,40 @@ public class ReferenceResult public List? BodyContentTruncationReasons { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExcerptRecoveryHint? BodyContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool CallsiteContentTruncated { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CallsiteContentTruncationReasons { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ExcerptRecoveryHint? CallsiteContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteColumn { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLength { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteSelection { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteOmittedReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContentUnavailableReason { get; set; } } public class CallerResult @@ -897,6 +933,8 @@ public class CallerResult public double ReferenceWeightScore { get; set; } public int FirstLine { get; set; } public int FirstColumn { get; set; } + [JsonIgnore] + public int? FirstLength { get; set; } public int ReferenceCount { get; set; } public bool HasSelfReference { get; set; } public bool HasMutualRecursion { get; set; } @@ -920,6 +958,40 @@ public class CallerResult public List? BodyContentTruncationReasons { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExcerptRecoveryHint? BodyContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool CallsiteContentTruncated { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CallsiteContentTruncationReasons { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ExcerptRecoveryHint? CallsiteContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteColumn { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLength { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteSelection { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteOmittedReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContentUnavailableReason { get; set; } } public class CalleeResult @@ -966,6 +1038,40 @@ public class CalleeResult public List? BodyContentTruncationReasons { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExcerptRecoveryHint? BodyContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool CallsiteContentTruncated { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CallsiteContentTruncationReasons { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ExcerptRecoveryHint? CallsiteContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteColumn { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLength { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteSelection { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteOmittedReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContentUnavailableReason { get; set; } } public class ImpactResult @@ -982,6 +1088,10 @@ public class ImpactResult public long? CalleeSymbolId { get; set; } public int Depth { get; set; } public int FirstLine { get; set; } + [JsonIgnore] + public int? FirstColumn { get; set; } + [JsonIgnore] + public int? FirstLength { get; set; } public int ReferenceCount { get; set; } public string ReferenceKind { get; set; } = string.Empty; public IReadOnlyList ReferenceKinds { get; set; } = Array.Empty(); @@ -1026,6 +1136,40 @@ public class ImpactResult public List? BodyContentTruncationReasons { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExcerptRecoveryHint? BodyContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool CallsiteContentTruncated { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteRequestedEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteEffectiveEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CallsiteContentTruncationReasons { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ExcerptRecoveryHint? CallsiteContentRecovery { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteColumn { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteLength { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteSelection { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CallsiteOmittedReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CallsiteContentUnavailableReason { get; set; } } public class ImpactPathNode diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 74fdd745d..1c69603fe 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -428,7 +428,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--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 ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]", output); Assert.Contains("--snippet-lines ", output); - Assert.Contains("Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", output); + Assert.Contains("Snippet length; graph --body uses it for definition and centered call-site evidence; issue-drafts accept 0 for path/line-only evidence", output); Assert.Contains("cdidx find (--path |--all)", output); Assert.Contains("--count", output); Assert.Contains("scan caps can still mark approximate counts as degraded", output); diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs index a0e59c139..7e0915e5b 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs @@ -29,7 +29,7 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() var firstArgs = new[] { command, query, "--db", dbPath, "--json", "--body", "--snippet-lines", "3", - "--fields", "path,line,body_content,body_start_line,body_end_line,body_content_truncated,body_requested_start_line,body_requested_end_line,body_effective_start_line,body_effective_end_line,body_content_truncation_reasons,body_content_recovery", + "--fields", "path,line,body_content,body_start_line,body_end_line,body_content_truncated,body_requested_start_line,body_requested_end_line,body_effective_start_line,body_effective_end_line,body_content_truncation_reasons,body_content_recovery,callsite_content,callsite_start_line,callsite_end_line,callsite_content_truncated,callsite_requested_start_line,callsite_requested_end_line,callsite_effective_start_line,callsite_effective_end_line,callsite_content_truncation_reasons,callsite_content_recovery,callsite_line,callsite_column,callsite_length,callsite_selection,callsite_reference_count,callsite_omitted_reference_count,callsite_content_unavailable_reason", "--limit", "1", "--max-json-bytes", "16384", "--exact", }; @@ -58,12 +58,36 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() { Assert.True(firstResult.TryGetProperty(field, out _), field); } + Assert.False(string.IsNullOrWhiteSpace(firstResult.GetProperty("callsite_content").GetString())); + Assert.Equal("first_reference", firstResult.GetProperty("callsite_selection").GetString()); + Assert.True(firstResult.GetProperty("callsite_line").GetInt32() > 0); + Assert.True(firstResult.GetProperty("callsite_column").GetInt32() > 0); + Assert.True(firstResult.GetProperty("callsite_reference_count").GetInt32() > 0); + Assert.True(firstResult.GetProperty("callsite_omitted_reference_count").GetInt32() >= 0); + foreach (var field in new[] + { + "callsite_start_line", + "callsite_end_line", + "callsite_requested_start_line", + "callsite_requested_end_line", + "callsite_effective_start_line", + "callsite_effective_end_line", + }) + { + Assert.True(firstResult.TryGetProperty(field, out _), field); + } if (firstResult.TryGetProperty("body_content_truncated", out var bodyContentTruncated)) { Assert.True(bodyContentTruncated.GetBoolean()); Assert.True(firstResult.TryGetProperty("body_content_truncation_reasons", out _)); Assert.True(firstResult.TryGetProperty("body_content_recovery", out _)); } + if (firstResult.TryGetProperty("callsite_content_truncated", out var callsiteContentTruncated)) + { + Assert.True(callsiteContentTruncated.GetBoolean()); + Assert.True(firstResult.TryGetProperty("callsite_content_truncation_reasons", out _)); + Assert.True(firstResult.TryGetProperty("callsite_content_recovery", out _)); + } var secondArgs = firstArgs.Concat(["--cursor", cursor]).ToArray(); var (secondExitCode, secondStdout, secondStderr) = CaptureConsole( @@ -92,12 +116,13 @@ public void GraphBodyIntent_IsIndependentFromBoundedProjection_Issue5094() try { var dbPath = CreateGraphFixture(projectRoot); - var projections = new (string? Fields, bool ExpectBody)[] + var projections = new (string? Fields, bool ExpectBody, bool ExpectCallsite)[] { - ("path,line", false), - ("file,line", false), - ("body_content,body_content_truncated,body_content_recovery", true), - ("all", true), + ("path,line", false, false), + ("file,line", false, false), + ("body_content,body_content_truncated,body_content_recovery", true, false), + ("callsite_content,callsite_line,callsite_column,callsite_selection", false, true), + ("all", true, true), }; foreach (var (command, query) in new[] @@ -122,6 +147,8 @@ public void GraphBodyIntent_IsIndependentFromBoundedProjection_Issue5094() { Assert.False(string.IsNullOrWhiteSpace( unprojectedDocument.RootElement.GetProperty("body_content").GetString())); + Assert.False(string.IsNullOrWhiteSpace( + unprojectedDocument.RootElement.GetProperty("callsite_content").GetString())); } foreach (var projection in projections) @@ -155,6 +182,13 @@ public void GraphBodyIntent_IsIndependentFromBoundedProjection_Issue5094() result.EnumerateObject().Select(property => property.Name), propertyName => propertyName.StartsWith("body_", StringComparison.Ordinal)); } + Assert.Equal(projection.ExpectCallsite, result.TryGetProperty("callsite_content", out _)); + if (!projection.ExpectCallsite) + { + Assert.DoesNotContain( + result.EnumerateObject().Select(property => property.Name), + propertyName => propertyName.StartsWith("callsite_", StringComparison.Ordinal)); + } } } } @@ -272,6 +306,131 @@ public void GraphBodyIntentValidation_PrecedesProjectionAndDatabaseAccess_Issue5 } } + [Fact] + public void ImpactBodyIntentValidation_PrecedesProjectionAndDatabaseAccess_Issue5099() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_impact_body_validation_5099"); + try + { + var missingDbPath = Path.Combine(projectRoot, "missing.db"); + var args = new[] + { + "impact", "Target", "--db", missingDbPath, "--json", "--fields", "path", + "--max-json-bytes", "8192", "--snippet-lines", "3", + }; + var (exitCode, stdout, stderr) = CaptureConsole( + () => ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + var diagnostic = stdout + stderr; + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains(CommandErrorCodes.UsageError, diagnostic); + Assert.Contains("--snippet-lines requires --body", diagnostic); + Assert.DoesNotContain("database", diagnostic, StringComparison.OrdinalIgnoreCase); + + var dbPath = CreateGraphFixture(projectRoot); + var (countExitCode, countStdout, countStderr) = CaptureConsole( + () => ProgramRunner.Run( + [ + "impact", "TargetA", "--db", dbPath, "--json", "--body", + "--snippet-lines", "3", "--count", + ], + _jsonOptions, + "1.0.0-test")); + var countDiagnostic = countStdout + countStderr; + + Assert.Equal(CommandExitCodes.UsageError, countExitCode); + Assert.Contains(CommandErrorCodes.UsageError, countDiagnostic); + Assert.Contains("--snippet-lines with --body requires text or JSON result output", countDiagnostic); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ImpactHiddenBodyOutputs_SkipCallsiteMaterialization_Issue5099() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_impact_hidden_body_5099"); + try + { + var dbPath = CreateGraphFixture(projectRoot); + var (countExitCode, countStdout, countStderr) = CaptureConsole( + () => ProgramRunner.Run( + [ + "impact", "TargetA", "--db", dbPath, "--json", "--body", "--count", + "--profile", "--limit", "2", "--exact-name", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + Assert.DoesNotContain("FROM chunks c", countStdout, StringComparison.Ordinal); + Assert.DoesNotContain("callsite_content", countStdout, StringComparison.Ordinal); + + var (visibleExitCode, visibleStdout, visibleStderr) = CaptureConsole( + () => QueryCommandRunner.RunImpact( + [ + "TargetA", "--db", dbPath, "--json", "--body", "--profile", + "--limit", "1", "--exact-name", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, visibleExitCode); + Assert.Equal(string.Empty, visibleStderr); + Assert.Contains("FROM chunks c", visibleStdout, StringComparison.Ordinal); + + var (compactExitCode, compactStdout, compactStderr) = CaptureConsole( + () => QueryCommandRunner.RunImpact( + [ + "TargetA", "--db", dbPath, "--body", "--format", "compact", "--profile", + "--limit", "1", "--exact-name", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, compactExitCode); + Assert.Equal(string.Empty, compactStderr); + Assert.DoesNotContain("FROM chunks c", compactStdout, StringComparison.Ordinal); + + var (projectedExitCode, projectedStdout, projectedStderr) = CaptureConsole( + () => ProgramRunner.Run( + [ + "impact", "TargetA", "--db", dbPath, "--json", "--body", "--fields", "path", + "--max-json-bytes", "8192", "--limit", "1", "--exact-name", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, projectedExitCode); + Assert.Equal(string.Empty, projectedStderr); + using var projectedDocument = JsonDocument.Parse(projectedStdout); + var projectedResult = projectedDocument.RootElement.GetProperty("results")[0]; + Assert.Equal(["path"], projectedResult.EnumerateObject().Select(property => property.Name)); + + var (callsiteExitCode, callsiteStdout, callsiteStderr) = CaptureConsole( + () => ProgramRunner.Run( + [ + "impact", "TargetA", "--db", dbPath, "--json", "--body", "--snippet-lines", "3", + "--fields", "callers.callsite_content,callers.callsite_line", "--max-json-bytes", "8192", + "--limit", "1", "--exact-name", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, callsiteExitCode); + Assert.Equal(string.Empty, callsiteStderr); + using var callsiteDocument = JsonDocument.Parse(callsiteStdout); + var callsiteResult = callsiteDocument.RootElement.GetProperty("results")[0]; + Assert.False(string.IsNullOrWhiteSpace(callsiteResult.GetProperty("callsite_content").GetString())); + Assert.True(callsiteResult.GetProperty("callsite_line").GetInt32() > 0); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void GraphFieldDiscovery_RemainsIndependentFromBodyIntent_Issue5094() { @@ -294,6 +453,10 @@ public void GraphFieldDiscovery_RemainsIndependentFromBodyIntent_Issue5094() Assert.Contains("body_content", validFields); Assert.Contains("body_content_truncated", validFields); Assert.Contains("body_content_recovery", validFields); + Assert.Contains("callsite_content", validFields); + Assert.Contains("callsite_content_truncated", validFields); + Assert.Contains("callsite_content_recovery", validFields); + Assert.Contains("callsite_selection", validFields); } } diff --git a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs index 449a17381..048752cdc 100644 --- a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs +++ b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs @@ -442,6 +442,26 @@ public void NonOutputFields_AreNotAdvertisedOrAccepted_Issue4836( item => item!.GetValue() == field); } + [Theory] + [InlineData("references.callsite_content")] + [InlineData("callers.callsite_line")] + [InlineData("callees.callsite_selection")] + public void InspectCallsiteFields_AreNotAdvertisedOrAccepted_Issue5099(string field) + { + Assert.False(ProjectionFieldRegistry.TryResolveInspectSelector( + field, + out _, + out _, + out _, + out var error)); + Assert.NotNull(error); + + var discovery = ProjectionFieldRegistry.CreateInspectDiscoveryDocument(); + Assert.DoesNotContain( + discovery["valid_fields"]!.AsArray(), + item => item!.GetValue() == field); + } + [Fact] public void CallGraphCompactDefaults_IncludeCallSiteColumns_Issue4836_Issue4841() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs index b52185351..683eea093 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs @@ -593,7 +593,10 @@ public void GraphCommands_BodyOptionAddsCappedBodyExcerpt_Issue1594() try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(dbPath, "src/Session.cs", "csharp", """ + var callsitePrefix = " var first = " + new string(' ', 320); + var callsiteLine = callsitePrefix + "runner" + new string(' ', 320) + ";"; + var callsiteColumn = callsitePrefix.Length + 1; + var source = $$""" class Session { int Run(int user) @@ -604,15 +607,18 @@ int Run(int user) int Login(int user) { - return Run(user); + {{callsiteLine}} + return Run(first); } } - """); + """; + TestProjectHelper.InsertIndexedFile(dbPath, "src/Session.cs", "csharp", source); + var fileId = 0; using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) { using var select = db.Connection.CreateCommand(); select.CommandText = "SELECT id FROM files WHERE path = 'src/Session.cs'"; - var fileId = Convert.ToInt32(select.ExecuteScalar()); + fileId = Convert.ToInt32(select.ExecuteScalar()); var writer = new DbWriter(db.Connection); writer.InsertReferences([ new ReferenceRecord @@ -621,41 +627,122 @@ int Login(int user) SymbolName = "Run", ReferenceKind = "call", Line = 11, + Column = callsiteColumn, + SpanLength = "runner".Length, + Context = callsiteLine, + ContainerKind = "function", + ContainerName = "Login", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "Run", + ReferenceKind = "call", + Line = 12, Column = 16, - Context = " return Run(user);", + SpanLength = "Run".Length, + Context = " return Run(first);", ContainerKind = "function", ContainerName = "Login", - } + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "LegacyRun", + ReferenceKind = "call", + Line = 12, + Column = 0, + Context = " return Run(first);", + ContainerKind = "function", + ContainerName = "LegacyLogin", + }, ]); writer.MarkGraphReady(); } AssertBodyExcerpt( QueryCommandRunner.RunReferences, - ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "1"], + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--max-line-width", "80", "--limit", "1", "--exact-name"], "int Login(int user)", - expectedContentTruncated: true); + expectedContentTruncated: true, + expectedCallsiteContent: "runner", + expectedCallsiteLine: 11, + expectedCallsiteColumn: callsiteColumn, + expectedCallsiteLength: "runner".Length, + expectedCallsiteReferenceCount: 1, + expectedCallsiteOmittedReferenceCount: 0, + expectedCallsiteContentTruncated: true, + forbiddenRecoveryPath: projectRoot); AssertBodyExcerpt( QueryCommandRunner.RunCallers, - ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "20"], + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--max-line-width", "80", "--exact-name"], "int Login(int user)", - expectedContentTruncated: false); + expectedContentTruncated: true, + expectedCallsiteContent: "runner", + expectedCallsiteLine: 11, + expectedCallsiteColumn: callsiteColumn, + expectedCallsiteLength: "runner".Length, + expectedCallsiteReferenceCount: 3, + expectedCallsiteOmittedReferenceCount: 2, + expectedCallsiteContentTruncated: true, + forbiddenRecoveryPath: projectRoot); AssertBodyExcerpt( QueryCommandRunner.RunCallees, - ["Login", "--db", dbPath, "--json", "--body", "--snippet-lines", "1"], + ["Login", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--max-line-width", "80", "--exact-name"], "int Run(int user)", - expectedContentTruncated: true); + expectedContentTruncated: true, + expectedCallsiteContent: "runner", + expectedCallsiteLine: 11, + expectedCallsiteColumn: callsiteColumn, + expectedCallsiteLength: "runner".Length, + expectedCallsiteReferenceCount: 3, + expectedCallsiteOmittedReferenceCount: 2, + expectedCallsiteContentTruncated: true, + forbiddenRecoveryPath: projectRoot); var (textExitCode, textStdout, textStderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( - ["Run", "--db", dbPath, "--body", "--snippet-lines", "1"], + ["Run", "--db", dbPath, "--body", "--snippet-lines", "1", "--max-line-width", "80", "--exact-name"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, textExitCode); Assert.Contains("int Login(int user)", textStdout); + Assert.Contains("Call site (line 11", textStdout); + Assert.Contains("runner", textStdout); Assert.Contains("references in", textStderr); + var (unavailableExitCode, unavailableStdout, unavailableStderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( + ["LegacyRun", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--exact-name"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, unavailableExitCode); + Assert.Equal(string.Empty, unavailableStderr); + using (var unavailableDocument = ParseJsonOutput(unavailableStdout)) + { + var unavailableResult = unavailableDocument.RootElement; + Assert.Equal(12, unavailableResult.GetProperty("callsite_line").GetInt32()); + Assert.Equal("first_reference", unavailableResult.GetProperty("callsite_selection").GetString()); + Assert.Equal("callsite_column_unavailable", unavailableResult.GetProperty("callsite_content_unavailable_reason").GetString()); + Assert.False(unavailableResult.TryGetProperty("callsite_column", out _)); + Assert.False(unavailableResult.TryGetProperty("callsite_content", out _)); + } + + var (legacyCallerExitCode, legacyCallerStdout, legacyCallerStderr) = CaptureConsole(() => QueryCommandRunner.RunCallers( + ["LegacyRun", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--exact-name"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, legacyCallerExitCode); + Assert.Equal(string.Empty, legacyCallerStderr); + using (var legacyCallerDocument = ParseJsonOutput(legacyCallerStdout)) + { + var legacyCaller = legacyCallerDocument.RootElement; + Assert.Equal(12, legacyCaller.GetProperty("callsite_line").GetInt32()); + Assert.Equal("callsite_column_unavailable", legacyCaller.GetProperty("callsite_content_unavailable_reason").GetString()); + Assert.False(legacyCaller.TryGetProperty("callsite_column", out _)); + Assert.False(legacyCaller.TryGetProperty("callsite_content", out _)); + } + var (impactExitCode, impactStdout, impactStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( - ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "2"], + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--max-line-width", "80", "--exact-name"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, impactExitCode); @@ -663,8 +750,74 @@ int Login(int user) using var impactDocument = ParseJsonOutput(impactStdout); var impactCaller = impactDocument.RootElement.GetProperty("callers")[0]; Assert.Contains("int Login(int user)", impactCaller.GetProperty("body_content").GetString()); - Assert.Equal(2, CountLines(impactCaller.GetProperty("body_content").GetString()!)); Assert.True(impactCaller.GetProperty("body_content_truncated").GetBoolean()); + AssertCallsiteEvidence( + impactCaller, + "runner", + expectedLine: 11, + expectedColumn: callsiteColumn, + expectedLength: "runner".Length, + expectedReferenceCount: 2, + expectedOmittedReferenceCount: 1, + expectedContentTruncated: true, + forbiddenRecoveryPath: projectRoot); + + var sourceLines = source.Split('\n'); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var replaceChunks = db.Connection.CreateCommand(); + replaceChunks.CommandText = @" + DELETE FROM chunks WHERE file_id = @fileId; + INSERT INTO chunks (file_id, chunk_index, start_line, end_line, content) + VALUES (@fileId, 0, 1, 10, @beforeGap), + (@fileId, 1, 12, @endLine, @afterGap);"; + replaceChunks.Parameters.AddWithValue("@fileId", fileId); + replaceChunks.Parameters.AddWithValue("@beforeGap", string.Join('\n', sourceLines.Take(10))); + replaceChunks.Parameters.AddWithValue("@endLine", sourceLines.Length); + replaceChunks.Parameters.AddWithValue("@afterGap", string.Join('\n', sourceLines.Skip(11))); + replaceChunks.ExecuteNonQuery(); + } + + var (gapExitCode, gapStdout, gapStderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "3", "--limit", "1", "--exact-name"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, gapExitCode); + Assert.Equal(string.Empty, gapStderr); + using (var gapDocument = ParseJsonOutput(gapStdout)) + { + var gapResult = gapDocument.RootElement; + Assert.Equal(11, gapResult.GetProperty("callsite_line").GetInt32()); + Assert.Equal(callsiteColumn, gapResult.GetProperty("callsite_column").GetInt32()); + Assert.Equal("runner".Length, gapResult.GetProperty("callsite_length").GetInt32()); + Assert.Equal("callsite_excerpt_unavailable", gapResult.GetProperty("callsite_content_unavailable_reason").GetString()); + Assert.False(gapResult.TryGetProperty("callsite_content", out _)); + } + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var clearColumn = db.Connection.CreateCommand(); + clearColumn.CommandText = @" + UPDATE symbol_references + SET column_number = NULL + WHERE file_id = @fileId AND symbol_name = 'Run' AND line = 11;"; + clearColumn.Parameters.AddWithValue("@fileId", fileId); + clearColumn.ExecuteNonQuery(); + } + + var (nullableExitCode, nullableStdout, nullableStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "1", "--exact-name"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, nullableExitCode); + Assert.Equal(string.Empty, nullableStderr); + using var nullableDocument = ParseJsonOutput(nullableStdout); + var nullableCaller = nullableDocument.RootElement.GetProperty("callers")[0]; + Assert.Equal(11, nullableCaller.GetProperty("callsite_line").GetInt32()); + Assert.Equal("runner".Length, nullableCaller.GetProperty("callsite_length").GetInt32()); + Assert.Equal("callsite_column_unavailable", nullableCaller.GetProperty("callsite_content_unavailable_reason").GetString()); + Assert.False(nullableCaller.TryGetProperty("callsite_column", out _)); + Assert.False(nullableCaller.TryGetProperty("callsite_content", out _)); } finally { @@ -688,7 +841,7 @@ public void GraphCommands_ExplicitSnippetLinesRequireVisibleBodyOutput_Issue4882 (["--body", "--snippet-lines", "3", "--count"], "--snippet-lines with --body requires text or JSON result output"), }; - foreach (var command in new[] { "references", "callers", "callees" }) + foreach (var command in new[] { "references", "callers", "callees", "impact" }) { foreach (var scenario in scenarios) { @@ -708,7 +861,7 @@ public void GraphCommands_ExplicitSnippetLinesRequireVisibleBodyOutput_Issue4882 [Fact] public void GraphCommands_SnippetLinesAboveMaximumKeepRangeError_Issue4882() { - foreach (var command in new[] { "references", "callers", "callees" }) + foreach (var command in new[] { "references", "callers", "callees", "impact" }) { var (exitCode, stdout, stderr) = CaptureConsole(() => RunGraphCommand( command, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index dd6d1318a..3e0f6d350 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -5969,7 +5969,15 @@ private void AssertBodyExcerpt( Func command, string[] args, string expectedContent, - bool? expectedContentTruncated = null) + bool? expectedContentTruncated = null, + string? expectedCallsiteContent = null, + int? expectedCallsiteLine = null, + int? expectedCallsiteColumn = null, + int? expectedCallsiteLength = null, + int? expectedCallsiteReferenceCount = null, + int? expectedCallsiteOmittedReferenceCount = null, + bool? expectedCallsiteContentTruncated = null, + string? forbiddenRecoveryPath = null) { var (exitCode, stdout, stderr) = CaptureConsole(() => command(args, _jsonOptions)); @@ -5983,6 +5991,57 @@ private void AssertBodyExcerpt( && property.GetBoolean(); Assert.Equal(expectedContentTruncated.Value, contentTruncated); } + + if (expectedCallsiteContent is not null) + { + AssertCallsiteEvidence( + document.RootElement, + expectedCallsiteContent, + expectedCallsiteLine ?? throw new ArgumentNullException(nameof(expectedCallsiteLine)), + expectedCallsiteColumn ?? throw new ArgumentNullException(nameof(expectedCallsiteColumn)), + expectedCallsiteLength ?? throw new ArgumentNullException(nameof(expectedCallsiteLength)), + expectedCallsiteReferenceCount ?? throw new ArgumentNullException(nameof(expectedCallsiteReferenceCount)), + expectedCallsiteOmittedReferenceCount ?? throw new ArgumentNullException(nameof(expectedCallsiteOmittedReferenceCount)), + expectedCallsiteContentTruncated ?? throw new ArgumentNullException(nameof(expectedCallsiteContentTruncated)), + forbiddenRecoveryPath ?? throw new ArgumentNullException(nameof(forbiddenRecoveryPath))); + } + } + + private static void AssertCallsiteEvidence( + JsonElement result, + string expectedContent, + int expectedLine, + int expectedColumn, + int expectedLength, + int expectedReferenceCount, + int expectedOmittedReferenceCount, + bool expectedContentTruncated, + string forbiddenRecoveryPath) + { + Assert.Equal(expectedLine, result.GetProperty("callsite_start_line").GetInt32()); + Assert.Equal(expectedLine, result.GetProperty("callsite_end_line").GetInt32()); + Assert.Equal(expectedLine, result.GetProperty("callsite_line").GetInt32()); + Assert.Equal(expectedColumn, result.GetProperty("callsite_column").GetInt32()); + Assert.Equal(expectedLength, result.GetProperty("callsite_length").GetInt32()); + Assert.Equal("first_reference", result.GetProperty("callsite_selection").GetString()); + Assert.Equal(expectedReferenceCount, result.GetProperty("callsite_reference_count").GetInt32()); + Assert.Equal(expectedOmittedReferenceCount, result.GetProperty("callsite_omitted_reference_count").GetInt32()); + Assert.Equal(expectedContentTruncated, result.GetProperty("callsite_content_truncated").GetBoolean()); + var callsiteContent = result.GetProperty("callsite_content").GetString(); + Assert.True( + callsiteContent?.Contains(expectedContent, StringComparison.Ordinal) == true, + $"Expected call-site content to contain '{expectedContent}', but got: {callsiteContent}"); + + var truncationReasons = result.GetProperty("callsite_content_truncation_reasons") + .EnumerateArray() + .Select(reason => reason.GetString()) + .OfType(); + Assert.Contains("line_width_cap", truncationReasons); + + var recovery = result.GetProperty("callsite_content_recovery"); + Assert.True(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.DoesNotContain(forbiddenRecoveryPath, recovery.GetRawText(), StringComparison.Ordinal); + Assert.False(result.TryGetProperty("callsite_content_unavailable_reason", out _)); } private static int CountLines(string text) => text.Split('\n').Length; @@ -9096,6 +9155,7 @@ private int RunCommandWithInvalidNumeric(string scenario) "references" => QueryCommandRunner.RunReferences(args, jsonOptions), "callers" => QueryCommandRunner.RunCallers(args, jsonOptions), "callees" => QueryCommandRunner.RunCallees(args, jsonOptions), + "impact" => QueryCommandRunner.RunImpact(args, jsonOptions), _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Unsupported graph command"), };