From 87b5b639a3a4f6c67c05e977b93854b0d6efead5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 24 Aug 2026 14:44:16 +0900 Subject: [PATCH 1/5] Add actionable C# top-level symbols (#5164) --- DEVELOPER_GUIDE.md | 48 ++++ TESTING_GUIDE.md | 2 + USER_GUIDE.md | 34 ++- changelog.d/unreleased/5164.added.md | 20 ++ .../Cli/QueryCommandRunner.ArgParsing.cs | 7 +- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 64 ++++- .../Cli/QueryCommandRunner.Outline.cs | 137 ++-------- .../Database/DbReader.GraphQueries.cs | 51 +++- .../Database/DbSymbolReader.Analysis.cs | 36 ++- .../Database/DbSymbolReader.Outline.cs | 62 +++-- ...eExtractor.CoreCSharpDocumentationLines.cs | 3 + .../Symbols/SymbolExtractor.CSharpTopLevel.cs | 170 ++++++++++++ .../Symbols/SymbolExtractor.Contracts.cs | 2 +- .../Symbols/SymbolExtractor.ExtractCore.cs | 2 +- .../SymbolExtractor.ExtractionPhases.cs | 4 + .../SymbolExtractor.PatternExtraction.cs | 1 + src/CodeIndex/Models/QueryResults.cs | 27 ++ .../Models/SyntheticSymbolIdentity.cs | 18 ++ .../IndexCommandRunnerFullScanTests.cs | 4 +- .../IndexCommandRunnerIssue5164Tests.cs | 109 ++++++++ .../McpServerToolsCallTests.cs | 28 ++ .../QueryCommandRunnerInspectTests.cs | 38 +-- .../QueryCommandRunnerIssue5164Tests.cs | 251 ++++++++++++++++++ .../SymbolExtractorCSharpTests.cs | 90 +++++++ tests/CodeIndex.Tests/TestProjectHelper.cs | 1 + 25 files changed, 1042 insertions(+), 167 deletions(-) create mode 100644 changelog.d/unreleased/5164.added.md create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs create mode 100644 src/CodeIndex/Models/SyntheticSymbolIdentity.cs create mode 100644 tests/CodeIndex.Tests/IndexCommandRunnerIssue5164Tests.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 31bce0ee3..9db7af5d2 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1139,6 +1139,32 @@ guard that would fail before users see multi-hour indexing stalls. Do not add mutable static caches, shared `StringBuilder` instances, reused `MatchCollection` enumerators, or singleton scanner state to extractor code. If a future extractor needs cross-call memoization, use an explicit thread-safe collection and add a targeted parallel regression test that proves deterministic output under concurrent calls. +### C# top-level synthetic scope contract + +C# extractor contract version 13 persists an actionable file-scoped symbol for +compilation units with executable top-level statements. The symbol uses +`kind=function`, `sub_kind=top_level_scope`, and `name=`; public +results derive `is_synthetic=true`, qualify the identity as +`::`, and emit an `id:@g:` +selector. Selectors are valid only for their active index generation and are +resolved by symbol id, so identical top-level programs in different files do +not share callee identity. + +Detection runs after container assignment. It excludes declaration-covered +ranges, imports, comments, directives, and assembly/module metadata, then uses +the first and last uncovered executable lines as both source and body bounds. +A top-level local function remains source-declared and containerless; its own +narrower span owns references inside the function, while a synthetic range may +cross it when executable statements occur on both sides. Reference extraction +uses the synthetic symbol's persisted id for otherwise containerless calls in +that body. The synthetic scope is not a documented declaration, so an XML-doc +comment before a top-level statement does not attach to it. `outline`, +coordinate `inspect`, and identity-scoped `callees` must +therefore navigate the same row. If a stored C# extractor version predates this +contract or is missing, and no synthetic row is available, outline reports +`top_level_symbol_support=reindex_required` plus a typed limitation instead of +claiming support. + ### Symbol Kind Taxonomy `symbols.kind`, `symbols.container_kind`, and `symbol_references.container_kind` use the public symbol kind taxonomy below. New extractors must register new kind values in `SymbolKindCatalog` before writing them so schema checks, writer validation, CLI filters, and downstream JSON consumers stay aligned. @@ -5089,6 +5115,28 @@ regression には、scope rule の focused correctness test と、ユーザー 抽出器コードに mutable な static cache、共有 `StringBuilder` インスタンス、使い回しの `MatchCollection` enumerator、シングルトンの scanner state を追加してはならない。将来の抽出器が呼び出しをまたぐ memoization を必要とする場合は、明示的にスレッドセーフなコレクションを使い、並行呼び出し下でも決定的な出力になることを証明する focused な並列回帰テストを追加する。 +### C# top-level synthetic scope 契約 + +C# extractor contract version 13 は、実行可能な top-level statement を持つ +compilation unit に、操作可能な file-scoped symbol を永続化します。この symbol は +`kind=function`、`sub_kind=top_level_scope`、`name=` を使い、公開結果は +`is_synthetic=true`、`::` 形式の qualified identity、 +`id:@g:` 形式の selector を派生させます。selector は active な +index generation だけで有効で、symbol id により解決するため、異なる file にある同一内容の +top-level program が callee identity を共有することはありません。 + +検出は container assignment の後に実行します。declaration が覆う range、import、comment、 +directive、assembly/module metadata を除外し、残った最初と最後の実行可能行を source/body +両方の境界にします。top-level local function は container を持たない source-declared symbol +のままで、その function 内の reference はより狭い自身の span が所有します。両側に実行 +statement がある場合、synthetic range は local function をまたぐことがあります。reference +extraction は、この body 内で従来 container を持たなかった call に synthetic symbol の永続化 +id を使います。synthetic scope は documented declaration ではないため、top-level statement +直前の XML-doc comment を自身へ結び付けません。そのため `outline`、座標指定 `inspect`、identity-scoped `callees` は同じ row を +navigate しなければなりません。保存済み C# extractor version がこの契約より古いか欠落しており、 +synthetic row も無い場合、outline は対応済みと見せず、`top_level_symbol_support=reindex_required` と型付きの +limitation を返します。 + ### シンボル種別分類 `symbols.kind`、`symbols.container_kind`、`symbol_references.container_kind` は diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 777fe5a4f..6640d6de9 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -560,6 +560,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result do not allocate production-sized 50k/20k inputs to test these contracts (#4620). - Broad extractor `*CompletesWithinPracticalBudget` runaway guards run only on the primary `net8.0` test target. Keep focused functional extractor tests cross-target, but do not duplicate the large-fixture budget guards across every target framework unless the guard is specifically proving a target-framework-specific contract. - C# reflection-name extraction coverage keeps literal, constant-concatenation, dynamic, comment, and string-decoy cases in one source fixture so those parser boundaries share one symbol/reference pass. +- C# top-level synthetic-scope coverage (#5164) keeps the 10-line entry-point, local-function ownership, and all import/metadata/comment/directive/type/explicit-`Main` negatives in focused extractor fixtures. One CLI reader fixture owns coordinate selection, cross-file selector isolation, caller/callee round-tripping, compact output, and stale-contract guidance; one MCP assertion owns transport naming parity. Keep full plus incremental no-op/change/add/rename/delete/no-op persistence in `IndexCommandRunnerIssue5164Tests.cs` so lifecycle coverage uses real index reconciliation instead of direct database fixtures. - C# BOM extraction keeps a simple leading-BOM import fixture plus one mixed-newline fixture that simultaneously covers leading and mid-file BOM handling across CRLF, bare CR, and LF boundaries; do not repeat separate extraction passes for newline subsets already present in the mixed fixture. - C# lambda-capture coverage keeps positive enclosing-local capture, parameter shadowing, and same-named-method isolation in one source fixture; a single capture assertion proves the negative regions did not leak. - C# static-lambda declaration regression coverage keeps stateful, typed/untyped, explicit-return, both async-modifier orders, Unicode/escaped-identifier, multiline, nested, and argument-position forms in one extractor pass. Preserve real static members/local functions, including generic, constructor, explicit-interface, and verbatim-type-name forms, and an assigned-lambda range/container assertion in that fixture, plus one CLI `symbols` corpus fixture for phantom-name checks (#4830). @@ -1682,6 +1683,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は高負荷な C# reference extraction に対する広めの runaway guard です。C# warmup は `CI=true` かつ `net8.0` test assembly の場合だけ、各 guard が fixture 構築と stopwatch 計測より前に呼ぶ `Lazy` gate により test process ごとに1回実行します。どちらの guard も含まない shard は extraction と強制 GC の固定 warmup cost を負いません。module initializer では hook discovery の delay、persistent worker PID/thread、persistent descendant PID/process の environment 処理をこの順序のまま eager に維持してください。warmup は最後に強制 GC を行うため、この2 guard だけを専用 non-parallel collection に保ち、巨大な `ReferenceExtractorTests` partial class 全体を non-parallel にしないでください。budget は benchmark 閾値ではなく回帰 tripwire として扱い、焦点を絞った最適化根拠がない限り noisy CI に十分な余裕を残してください。 - extractor の広い `*CompletesWithinPracticalBudget` runaway guard は primary の `net8.0` test target だけで実行します。focused な extractor 機能テストは cross-target のまま維持しますが、その guard が target-framework 固有の契約を証明する場合を除き、大規模 fixture の budget guard をすべての target framework で重複実行しないでください。 - C# reflection-name 抽出 coverage は、literal、定数連結、dynamic、comment、string decoy を1つの source fixture にまとめ、これらの parser boundary で1回の symbol/reference pass を共有します。 +- C# top-level synthetic scope の coverage (#5164) は、10行の entry point、local-function ownership、import / metadata / comment / directive / type / explicit `Main` の全 negative を focused extractor fixture に維持します。1つの CLI reader fixture が座標選択、file 間の selector 分離、caller/callee round-trip、compact 出力、stale-contract guidance を担当し、MCP の1 assertion が transport の命名 parity を担当します。full と incremental の no-op / change / add / rename / delete / no-op 永続化は `IndexCommandRunnerIssue5164Tests.cs` に維持し、direct database fixture ではなく実際の index reconciliation で lifecycle を検証してください。 - C# BOM 抽出は、単純な先頭 BOM import fixture と、CRLF・bare CR・LF 境界で先頭/mid-file BOM を同時に扱う1つの混在改行 fixture を維持します。混在 fixture に含まれる改行 subset ごとに抽出 pass を重複させないでください。 - C# lambda capture coverage は、外側 local の正例、parameter shadowing、同名 method 間の分離を1つの source fixture にまとめます。capture が1件だけである assertion により、negative region からの漏れも同時に検証します。 - C# static lambda の宣言回帰 coverage は、stateful、型あり/型なし、明示的戻り値型、両方の async modifier 順、Unicode/escape 識別子、複数行、入れ子、引数位置の各形式を1回の extractor pass にまとめます。同じ fixture で generic、constructor、明示的 interface、verbatim 型名を含む本物の static member / local function と、代入済み lambda の range / container assertion を維持し、phantom 名の確認には CLI `symbols` corpus fixture を1つ追加します(#4830)。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d473915e6..a6b14cf8e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2048,7 +2048,22 @@ cdidx outline src/CodeIndex/Cli/QueryCommandRunner.cs --compact --kind function Shows all symbols in a single file ordered deterministically by line, start column when available, kind, and name, with signature, visibility, and container nesting. Lets AI agents understand file structure in one call instead of reading the whole file or chaining `symbols` + `definition`. -For large files, `outline --json` supports `--kind `, `--sort `, `--limit` / `--top`, opaque `--cursor `, `--max-json-bytes `, and `--outline-fields ` so automation can request only the symbol page and fields it needs. Use `--sort size` (alias `span`) or `--sort complexity` to jump to large bodies first, and combine it with `--compact` for bounded giant-file triage. With `--max-json-bytes`, outline returns the shared bounded envelope, counts complete UTF-8 symbol rows plus the final newline, and uses an opaque `response:v2` continuation cursor. If even the minimum envelope cannot fit, it writes no stdout and reports typed `E010_USAGE_ERROR` diagnostics. Without a byte cap, the existing outline JSON shape and cursor contract remain unchanged. Controlled uncapped JSON output includes `total_symbol_count`, `returned_symbol_count`, `cursor_offset`, `next_cursor`, `has_more`, and `result_stable_at`; it also reports `sort`, `kind_filter`, and `selected_fields` when those controls are used. The cursor is bound to the file path, filters, ordering, and index generation, so changing them or refreshing the index requires restarting without `--cursor`. Pass `--outline-fields all` to keep the full symbol payload while still opting into the paging metadata, or select `reference_count`, `size_lines`, `complexity_score`, and `sort_mode` for compact ranking evidence. +C# files with executable top-level statements expose a file-scoped synthetic +`function` named ``. JSON and MCP identify it with +`sub_kind: "top_level_scope"`, `is_synthetic: true`, a +`::` `qualified_name`, and a snapshot-bound `selector` that can +be passed directly to `callees` (optionally with `--path`) and is distinct for +each file. Coordinate `inspect --path --line ` selects the same +node on executable lines. Its span runs from the first through the last +executable top-level line while excluding imports, comments, directives, and +compilation metadata where possible. A top-level local function remains a +normal declared function with its own narrower reference ownership; when it is +interleaved between statements, the synthetic range may span across it. Older +indexes report `top_level_symbol_support: "reindex_required"` and a typed +`top_level_symbol_limitation`; rerun `cdidx index ` before using this +navigation contract. + +For large files, `outline --json` supports `--kind `, `--sort `, `--limit` / `--top`, opaque `--cursor `, `--max-json-bytes `, and `--outline-fields ` so automation can request only the symbol page and fields it needs. Use `--sort size` (alias `span`) or `--sort complexity` to jump to large bodies first, and combine it with `--compact` for bounded giant-file triage. With `--max-json-bytes`, outline returns the shared bounded envelope, counts complete UTF-8 symbol rows plus the final newline, and uses an opaque `response:v2` continuation cursor. If even the minimum envelope cannot fit, it writes no stdout and reports typed `E010_USAGE_ERROR` diagnostics. Without a byte cap, the existing outline JSON shape and cursor contract remain unchanged. Controlled uncapped JSON output includes `total_symbol_count`, `returned_symbol_count`, `cursor_offset`, `next_cursor`, `has_more`, and `result_stable_at`; it also reports `sort`, `kind_filter`, and `selected_fields` when those controls are used. The cursor is bound to the file path, filters, ordering, and index generation, so changing them or refreshing the index requires restarting without `--cursor`. Pass `--outline-fields all` to keep the full symbol payload while still opting into the paging metadata. Synthetic identity can be projected with `symbol_id`, `sub_kind`, `is_synthetic`, `selector`, and `qualified_name`; select `reference_count`, `size_lines`, `complexity_score`, and `sort_mode` for compact ranking evidence. ### Reconstruct a file excerpt @@ -5647,7 +5662,22 @@ cdidx outline src/CodeIndex/Cli/QueryCommandRunner.cs --compact --kind function 1ファイル内の全シンボルを行、利用可能な場合は開始列、種別、名前の決定的な順序で、シグネチャ・可視性・コンテナ深さに応じたネスト付きで表示します。ファイル全体を読んだり `symbols` + `definition` をチェーンしたりする代わりに、1回でファイル構造を把握できます。 -大きなファイル向けに、`outline --json` は `--kind `、`--sort `、`--limit` / `--top`、opaque な `--cursor `、`--max-json-bytes `、`--outline-fields ` に対応します。自動化側は必要なシンボルページとフィールドだけを取得できます。`--sort size`(`span` alias)や `--sort complexity` を使うと大きい本体を先に確認でき、`--compact` と組み合わせると巨大ファイル調査向けの上限付きペイロードになります。`--max-json-bytes` を指定すると、outline は共通 bounded envelope を返し、最後の改行を含む完全な UTF-8 symbol row 単位で計測して opaque な `response:v2` continuation cursor を使用します。最小 envelope さえ収まらない場合は stdout を空に保ち、型付きの `E010_USAGE_ERROR` diagnostic を報告します。byte cap がない場合、既存の outline JSON 形状と cursor 契約は変わりません。上限なしの制御付き JSON 出力には `total_symbol_count`、`returned_symbol_count`、`cursor_offset`、`next_cursor`、`has_more`、`result_stable_at` が入り、sort、kind、field を指定した場合は `sort`、`kind_filter`、`selected_fields` も返します。cursor は file path、filter、ordering、index generation に束縛されるため、それらを変更した場合や index を更新した場合は `--cursor` なしで再開してください。`--outline-fields all` を渡すと、シンボルペイロードはフルのままページングメタデータだけを追加できます。ランキング根拠だけが必要な場合は `reference_count`、`size_lines`、`complexity_score`、`sort_mode` を選択できます。 +実行可能な top-level statement を含む C# file は、`` という名前の +file-scoped synthetic `function` を公開します。JSON / MCP では +`sub_kind: "top_level_scope"`、`is_synthetic: true`、 +`::` 形式の `qualified_name`、snapshot に束縛された +`selector` で識別します。selector は file ごとに異なり、`callees` の positional +値として直接渡せます(必要に応じて `--path` も指定できます)。実行行に対する +`inspect --path --line ` も同じ node を選択します。span は import、 +comment、directive、compilation metadata を可能な範囲で除外し、最初から最後の +実行可能 top-level 行までを覆います。top-level local function は通常の source-declared +function として、より狭い reference ownership を維持します。statement の間に挟まる +場合、synthetic range はその local function をまたぐことがあります。旧 index は +`top_level_symbol_support: "reindex_required"` と型付きの +`top_level_symbol_limitation` を返すため、この navigation 契約を使う前に +`cdidx index ` を再実行してください。 + +大きなファイル向けに、`outline --json` は `--kind `、`--sort `、`--limit` / `--top`、opaque な `--cursor `、`--max-json-bytes `、`--outline-fields ` に対応します。自動化側は必要なシンボルページとフィールドだけを取得できます。`--sort size`(`span` alias)や `--sort complexity` を使うと大きい本体を先に確認でき、`--compact` と組み合わせると巨大ファイル調査向けの上限付きペイロードになります。`--max-json-bytes` を指定すると、outline は共通 bounded envelope を返し、最後の改行を含む完全な UTF-8 symbol row 単位で計測して opaque な `response:v2` continuation cursor を使用します。最小 envelope さえ収まらない場合は stdout を空に保ち、型付きの `E010_USAGE_ERROR` diagnostic を報告します。byte cap がない場合、既存の outline JSON 形状と cursor 契約は変わりません。上限なしの制御付き JSON 出力には `total_symbol_count`、`returned_symbol_count`、`cursor_offset`、`next_cursor`、`has_more`、`result_stable_at` が入り、sort、kind、field を指定した場合は `sort`、`kind_filter`、`selected_fields` も返します。cursor は file path、filter、ordering、index generation に束縛されるため、それらを変更した場合や index を更新した場合は `--cursor` なしで再開してください。`--outline-fields all` を渡すと、シンボルペイロードはフルのままページングメタデータだけを追加できます。synthetic identity は `symbol_id`、`sub_kind`、`is_synthetic`、`selector`、`qualified_name` で projection でき、ランキング根拠だけが必要な場合は `reference_count`、`size_lines`、`complexity_score`、`sort_mode` を選択できます。 ### ファイル抜粋を再構成する diff --git a/changelog.d/unreleased/5164.added.md b/changelog.d/unreleased/5164.added.md new file mode 100644 index 000000000..543f70904 --- /dev/null +++ b/changelog.d/unreleased/5164.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 5164 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs + - src/CodeIndex/Database/DbSymbolReader.Outline.cs + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# top-level program bodies are now actionable synthetic symbols (#5164)** — `outline`, coordinate `inspect`, CLI JSON/compact output, and MCP expose a file-qualified `` function with an explicit synthetic marker and snapshot selector; passing that selector to `callees` now round-trips the same file-scoped caller identity without colliding across files, while stale indexes request a reindex. + +## 日本語 + +- **C# top-level program body を操作可能な synthetic symbol として公開するようになりました (#5164)** — `outline`、座標指定 `inspect`、CLI JSON/compact 出力、MCP は、明示的な synthetic marker と snapshot selector を持つ file-qualified な `` function を公開します。その selector を `callees` に渡すと file 間で衝突せず同じ file-scoped caller identity を往復でき、stale index には reindex を案内します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index f52907ca4..c59756596 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -350,7 +350,12 @@ void AddField(string field) case "all": all = true; continue; + case "symbol_id": case "kind": + case "sub_kind": + case "is_synthetic": + case "selector": + case "qualified_name": case "name": case "display_name": case "path": @@ -408,7 +413,7 @@ void AddField(string field) { var invalidValues = string.Join(", ", invalidFields.Select(field => $"'{ConsoleUi.FormatBoundedValue(field)}'")); var valueLabel = invalidFields.Count == 1 ? "value" : "values"; - addParseError($"Error: unsupported --outline-fields {valueLabel} {invalidValues}. Use one or more of all, kind, name, display_name, path, line, start_line, end_line, depth, body_start_line, body_end_line, signature, signature_truncated, signature_original_length, container_kind, container_name, visibility, return_type, sort_mode, reference_count, size_lines, complexity_score, or aliases range, lines, body, body_range, container, refs, size, span, complexity."); + addParseError($"Error: unsupported --outline-fields {valueLabel} {invalidValues}. Use one or more of all, symbol_id, kind, sub_kind, is_synthetic, selector, qualified_name, name, display_name, path, line, start_line, end_line, depth, body_start_line, body_end_line, signature, signature_truncated, signature_original_length, container_kind, container_name, visibility, return_type, sort_mode, reference_count, size_lines, complexity_score, or aliases range, lines, body, body_range, container, refs, size, span, complexity."); return all ? null : fields; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 290ab9318..a70f53e92 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CodeIndex.Database; +using CodeIndex.Models; namespace CodeIndex.Cli; @@ -325,19 +326,61 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return queryExitCode; } var query = options.Query!; + var selectedSymbol = SymbolSelector.TryParse(query, out var parsedSelector) + ? parsedSelector + : (SymbolSelector?)null; return WithDb(options, jsonOptions, reader => { + DefinitionResult? selectedDefinition = null; + if (selectedSymbol is { } selector) + { + if (!reader.IsCurrentSymbolSelector(selector)) + { + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"symbol selector is stale or belongs to another index generation: {selector}", + CommandExitCodes.NotFound, + "Rerun outline or inspect against this database and use the current emitted selector.", + errorCode: CommandErrorCodes.QueryNotFound, + category: "not_found"); + } + + selectedDefinition = reader.GetDefinitionBySelector(selector, options.Lang); + if (selectedDefinition == null) + { + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"symbol selector was not found in the active index: {selector}", + CommandExitCodes.NotFound, + "Rerun outline or inspect and use a selector emitted by the active database.", + errorCode: CommandErrorCodes.QueryNotFound, + category: "not_found"); + } + } + WriteGraphReferenceKindHint("callees", options.Kind, options.Json); WriteReferenceGraphCompletenessWarningIfNeeded(options.Json, reader); var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var hdlGraphSignal = reader.GetHdlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); - var exactGraphLanguage = exact + var exactGraphLanguage = selectedDefinition?.Lang ?? (exact ? reader.GetExactGraphSupportedDefinitionLanguage(query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) - : null; + : null); if (options.CountOnly) { - var counts = reader.CountCalleesTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.IncludeQualifiedCommonCalls, options.IncludeMemberReads); + var counts = selectedDefinition != null + ? reader.CountCalleesForCandidate( + selectedDefinition, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + options.Kind, + options.RawKinds, + options.IncludeQualifiedCommonCalls, + options.IncludeMemberReads) + : reader.CountCalleesTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.IncludeQualifiedCommonCalls, options.IncludeMemberReads); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); @@ -361,7 +404,20 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } - var results = reader.GetCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls, includeMemberReads: options.IncludeMemberReads); + var results = selectedDefinition != null + ? reader.GetCalleesForCandidate( + selectedDefinition, + options.Limit, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees"), + referenceKind: options.Kind, + rawKinds: options.RawKinds, + rankMode: options.RankMode, + includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls, + includeMemberReads: options.IncludeMemberReads) + : reader.GetCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls, includeMemberReads: options.IncludeMemberReads); if (options.IncludeBody && JsonEnvelopeWrapper.ShouldMaterializeBody("callees")) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs index 9d749bc06..e2f42c9af 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs @@ -161,10 +161,13 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions } else { - var outlineContent = reader.GetExcerpt(filePath, 1, outline.TotalLines)?.Content; - Console.WriteLine($"# {outline.Path} ({outline.Lang ?? "unknown"}, {outline.TotalLines} lines, {filteredSymbols.Count} symbols)"); Console.WriteLine(); + if (outline.TopLevelSymbolSupport == "reindex_required") + { + CommandErrorWriter.WriteStderr( + "Note: this index predates C# top-level synthetic symbols; reindex with the current cdidx binary."); + } var duplicateNames = filteredSymbols .GroupBy(sym => sym.Name, StringComparer.Ordinal) .Where(group => group.Count() > 1) @@ -185,23 +188,10 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions var vis = !useDisplayName && sym.Visibility != null && !sig.TrimStart().StartsWith(sym.Visibility, StringComparison.Ordinal) ? $"{sym.Visibility} " : ""; - Console.WriteLine($" {sym.Line,5} {indent}{vis}{sig} {ret}"); - } - - // AI-orientation hint for C# files that look like top-level-statements programs: - // no class / struct / interface / enum / namespace / record / delegate at all - // means the executable body lives between the imports and local functions and - // will not appear in outline at all. Emitting a short note on stderr keeps the - // main human-readable block clean while giving AI consumers a reason for the gap. - // AI向けヒント: C# のトップレベルステートメント想定のファイル - // (class / struct / interface / enum / namespace / record / delegate が一切無い)は、 - // 実行本体が import と local function の間に書かれるため outline に現れない。 - // 人間向け本体を汚さないよう、理由を短く stderr に出す。 - if (LooksLikeCsharpTopLevelStatements(outline, outlineContent)) - { - CommandErrorWriter.WriteStderr(); - CommandErrorWriter.WriteStderr("Note: no type/namespace declarations found; this file likely uses C# top-level statements."); - CommandErrorWriter.WriteStderr(" Outline lists imports and local functions only; the executable body is not indexed as symbols."); + var syntheticDetails = sym.IsSynthetic == true + ? $" [synthetic {sym.Kind}/{sym.SubKind}, lines {sym.StartLine}-{sym.EndLine}, selector {sym.Selector}]" + : ""; + Console.WriteLine($" {sym.Line,5} {indent}{vis}{sig} {ret}{syntheticDetails}"); } } return CommandExitCodes.Success; @@ -540,6 +530,8 @@ private static OutlineResult BuildOutlineView(OutlineResult outline, List - /// Heuristic: hint only when a non-trivial C# file has no type/namespace declarations and - /// its reconstructed content still contains uncovered file-scope executable code after - /// skipping symbol-covered lines, imports, metadata-only attribute lines, comments, and - /// preprocessor directives. This keeps the note off common files such as GlobalUsings.cs, - /// AssemblyInfo.cs, and local-function-only files while preserving statement-only Program.cs - /// files. - /// Tiny files (snippets, partials under ~20 lines) are excluded to avoid noise. - /// ヒューリスティック: 20 行以上の C# ファイルで型/名前空間宣言が無く、かつ - /// import 行、metadata-only 属性行、コメント、プリプロセッサ行を除いても - /// file-scope の実行コードが残る場合だけヒントを出す。これにより GlobalUsings.cs や - /// AssemblyInfo.cs の誤検出を避けつつ、 - /// statement-only の Program.cs は拾い続ける。小さい断片はノイズ回避のため除外。 - /// - private static bool LooksLikeCsharpTopLevelStatements(OutlineResult outline, string? content) - { - if (outline.Lang != "csharp") return false; - if (outline.TotalLines < 20) return false; - foreach (var sym in outline.Symbols) - { - if (sym.Kind is "class" or "struct" or "interface" or "enum" or "namespace" or "delegate" or "record") - return false; - } - - if (string.IsNullOrWhiteSpace(content)) - return false; - - var coveredLines = new bool[Math.Max(outline.TotalLines, 0) + 1]; - foreach (var sym in outline.Symbols) - { - var startLine = sym.StartLine > 0 ? sym.StartLine : sym.Line; - var endLine = sym.EndLine >= startLine ? sym.EndLine : startLine; - startLine = Math.Max(1, startLine); - endLine = Math.Min(outline.TotalLines, endLine); - for (var lineNumber = startLine; lineNumber <= endLine; lineNumber++) - coveredLines[lineNumber] = true; - } - - var inBlockComment = false; - var currentLineNumber = 0; - foreach (var rawLine in content.Split('\n')) - { - currentLineNumber++; - var line = rawLine.Trim(); - if (line.Length == 0) - continue; - if (currentLineNumber < coveredLines.Length && coveredLines[currentLineNumber]) - continue; - - if (inBlockComment) - { - if (line.Contains("*/", StringComparison.Ordinal)) - inBlockComment = false; - continue; - } - - if (line.StartsWith("/*", StringComparison.Ordinal)) - { - if (!line.Contains("*/", StringComparison.Ordinal)) - inBlockComment = true; - continue; - } - - if (line.StartsWith("using ", StringComparison.Ordinal)) - { - if (line.StartsWith("using var ", StringComparison.Ordinal)) - return true; - if (line.StartsWith("using (", StringComparison.Ordinal)) - return true; - continue; - } - if (line.StartsWith("global using ", StringComparison.Ordinal)) - continue; - if (line.StartsWith("extern alias ", StringComparison.Ordinal)) - continue; - if (line.StartsWith("[assembly:", StringComparison.Ordinal)) - continue; - if (line.StartsWith("[module:", StringComparison.Ordinal)) - continue; - if (line.StartsWith("//", StringComparison.Ordinal)) - continue; - if (line.StartsWith("*", StringComparison.Ordinal)) - continue; - if (line.StartsWith("*/", StringComparison.Ordinal)) - continue; - if (line.StartsWith("#", StringComparison.Ordinal)) - continue; - return true; - } - - return false; - } } diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 5c1267f31..565fb6228 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -291,26 +291,59 @@ private QueryCountResult CountCallersTotalCore( public List GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, int offset = 0, bool includeQualifiedCommonCalls = false, bool includeMemberReads = false) => GetCalleesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, offset, includeQualifiedCommonCalls, includeMemberReads, sourceSymbolId: null); - private List GetCalleesForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, int offset = 0) - => GetCalleesCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, offset, includeQualifiedCommonCalls: false, includeMemberReads: false, sourceSymbolId: definition.SymbolId); + internal List GetCalleesForCandidate( + DefinitionResult definition, + int limit, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + int offset = 0, + string? referenceKind = null, + bool rawKinds = false, + ReferenceRankMode rankMode = ReferenceRankMode.Weighted, + bool includeQualifiedCommonCalls = false, + bool includeMemberReads = false) + => GetCalleesCore( + definition.Name, + limit, + definition.Lang, + referenceKind, + pathPatterns, + excludePathPatterns, + excludeTests, + exact: true, + rawKinds, + rankMode, + offset, + includeQualifiedCommonCalls, + includeMemberReads, + sourceSymbolId: definition.SymbolId); - private int CountCalleesForCandidate(DefinitionResult definition, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + internal QueryCountResult CountCalleesForCandidate( + DefinitionResult definition, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + string? referenceKind = null, + bool rawKinds = false, + bool includeQualifiedCommonCalls = false, + bool includeMemberReads = false) { if (definition.SymbolId is not long symbolId || !_referenceColumns.Contains("source_symbol_id")) - return 0; + return new QueryCountResult(0, 0); return CountCalleesTotalCore( definition.Name, definition.Lang, - referenceKind: null, + referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact: true, - rawKinds: false, - includeQualifiedCommonCalls: false, - includeMemberReads: false, - symbolId).Count; + rawKinds, + includeQualifiedCommonCalls, + includeMemberReads, + symbolId); } private List GetCalleesCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, int offset, bool includeQualifiedCommonCalls, bool includeMemberReads, long? sourceSymbolId) diff --git a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs index 9f18e6cf7..9b3c4dc92 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs @@ -189,7 +189,8 @@ private List GetSymbolsAtLine(string path, int line, int limit, st {GetSymbolColumnSql("visibility")} AS visibility, {GetSymbolColumnSql("return_type")} AS return_type, s.id AS symbol_id, - {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name + {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name, + {GetSymbolColumnSql("sub_kind")} AS sub_kind FROM symbols s JOIN files f ON s.file_id = f.id WHERE f.path = @path @@ -245,6 +246,7 @@ private static SymbolResult ReadSymbolResult(SqliteDataReader reader) ReturnType = GetNullableString(reader, 13), SymbolId = reader.GetInt64(14), ContainerQualifiedName = GetNullableString(reader, 15), + SubKind = GetNullableString(reader, 16), }; private DefinitionResult? GetDefinitionBySymbolId( @@ -268,7 +270,8 @@ private static SymbolResult ReadSymbolResult(SqliteDataReader reader) {GetSymbolColumnSql("visibility")} AS visibility, {GetSymbolColumnSql("return_type")} AS return_type, s.id AS symbol_id, - {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name + {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name, + {GetSymbolColumnSql("sub_kind")} AS sub_kind FROM symbols s JOIN files f ON s.file_id = f.id WHERE s.id = @symbol_id"; @@ -614,7 +617,7 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( ? CountCallersTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count : 0; var calleeTotal = identityAvailable - ? CountCalleesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) + ? CountCalleesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests).Count : includeNameFallback ? CountCalleesTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count : 0; @@ -729,9 +732,11 @@ LIMIT 1 private SymbolCandidateSelector BuildSymbolCandidateSelector(DefinitionResult definition) { var container = definition.ContainerQualifiedName ?? definition.ContainerName; - var qualifiedName = string.IsNullOrWhiteSpace(container) - ? definition.Name - : $"{container}.{definition.Name}"; + var qualifiedName = definition.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind + ? SyntheticSymbolIdentity.BuildFileQualifiedName(definition.Path, definition.Name) + : string.IsNullOrWhiteSpace(container) + ? definition.Name + : $"{container}.{definition.Name}"; var generationFingerprint = SymbolSelector.BuildGenerationFingerprint( GetSymbolSelectorGenerationIdentity()); var selector = definition.SymbolId is long symbolId @@ -750,9 +755,28 @@ private SymbolCandidateSelector BuildSymbolCandidateSelector(DefinitionResult de Line = definition.StartLine, Lang = definition.Lang, Kind = definition.Kind, + SubKind = definition.SubKind, }; } + internal bool IsCurrentSymbolSelector(SymbolSelector selector) + => selector.GenerationFingerprint == null + || string.Equals( + selector.GenerationFingerprint, + SymbolSelector.BuildGenerationFingerprint(GetSymbolSelectorGenerationIdentity()), + StringComparison.Ordinal); + + internal DefinitionResult? GetDefinitionBySelector(SymbolSelector selector, string? lang = null) + => IsCurrentSymbolSelector(selector) + ? GetDefinitionBySymbolId( + selector.SymbolId, + includeBody: false, + bodyStartLine: null, + bodyLineCount: null, + lang, + kind: null) + : null; + private static List PrioritizeSourceDefinitions(List definitions) { if (definitions.Count <= 1) diff --git a/src/CodeIndex/Database/DbSymbolReader.Outline.cs b/src/CodeIndex/Database/DbSymbolReader.Outline.cs index 0af0d99b0..ee73deb5a 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Outline.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Outline.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.RegularExpressions; using CodeIndex.Indexer; +using CodeIndex.Models; using Microsoft.Data.Sqlite; using Regex = CodeIndex.Indexer.BoundedRegex; @@ -84,7 +85,7 @@ ON symbol_defs.lang IS @lang using var symCmd = _conn.CreateCommand(); symCmd.CommandText = $@" {symbolRankCteSql} - SELECT s.kind, s.name, s.line, + SELECT s.id, s.kind, {GetSymbolColumnSql("sub_kind")} AS sub_kind, s.name, s.line, {GetSymbolColumnSql("start_line", "s.line")} AS start_line, {GetSymbolColumnSql("end_line", "s.line")} AS end_line, {GetSymbolColumnSql("body_start_line")} AS body_start_line, @@ -111,32 +112,45 @@ FROM symbols s var symbols = new List(); var isJsonStructuredData = lang is "json" or "jsonl"; + var selectorGenerationFingerprint = SymbolSelector.BuildGenerationFingerprint( + GetSymbolSelectorGenerationIdentity()); using (var reader = symCmd.ExecuteTrackedReader()) { while (reader.TrackedRead()) { - var name = reader.GetString(1); - var containerName = GetNullableString(reader, 9); - var containerQualifiedName = GetNullableString(reader, 10); + var symbolId = reader.GetInt64(0); + var subKind = GetNullableString(reader, 2); + var name = reader.GetString(3); + var containerName = GetNullableString(reader, 11); + var containerQualifiedName = GetNullableString(reader, 12); + var isSynthetic = SyntheticSymbolIdentity.IsSyntheticSubKind(subKind); symbols.Add(new OutlineSymbol { - Kind = reader.GetString(0), + SymbolId = isSynthetic ? symbolId : null, + Kind = reader.GetString(1), + SubKind = subKind, Name = name, - Line = reader.GetInt32(2), - StartLine = GetInt32OrFallback(reader, 3, 2), - EndLine = GetInt32OrFallback(reader, 4, 2), - BodyStartLine = GetNullableInt32(reader, 5), - BodyEndLine = GetNullableInt32(reader, 6), - Signature = GetNullableString(reader, 7), - ContainerKind = GetNullableString(reader, 8), + Line = reader.GetInt32(4), + StartLine = GetInt32OrFallback(reader, 5, 4), + EndLine = GetInt32OrFallback(reader, 6, 4), + BodyStartLine = GetNullableInt32(reader, 7), + BodyEndLine = GetNullableInt32(reader, 8), + Signature = GetNullableString(reader, 9), + ContainerKind = GetNullableString(reader, 10), ContainerName = containerName, Path = BuildOutlineSymbolPath( containerQualifiedName ?? containerName, name, isJsonStructuredData), - Visibility = GetNullableString(reader, 11), - ReturnType = GetNullableString(reader, 12), - ReferenceCount = GetNullableInt32(reader, 13), + Selector = isSynthetic + ? new SymbolSelector(symbolId, selectorGenerationFingerprint).ToString() + : null, + QualifiedName = isSynthetic + ? SyntheticSymbolIdentity.BuildFileQualifiedName(filePath, name) + : null, + Visibility = GetNullableString(reader, 13), + ReturnType = GetNullableString(reader, 14), + ReferenceCount = GetNullableInt32(reader, 15), }); } } @@ -145,12 +159,30 @@ FROM symbols s ApplyQueryOutputSignatureLimits(symbols); PopulateOutlineDisplayNames(symbols, lang); + var hasCSharpTopLevelSymbol = symbols.Any(symbol => + symbol.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + var storedCSharpExtractorVersion = lang == "csharp" + ? GetMetaString(DbContext.GetSymbolExtractorVersionMetaKey("csharp")) + : null; + var csharpTopLevelContractCurrent = lang == "csharp" + && (hasCSharpTopLevelSymbol + || string.Equals( + storedCSharpExtractorVersion, + SymbolExtractor.CSharpContractVersion.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)); + return new OutlineResult { Path = filePath, Lang = lang, TotalLines = totalLines, SymbolCount = symbols.Count, + TopLevelSymbolSupport = lang == "csharp" + ? csharpTopLevelContractCurrent ? "indexed" : "reindex_required" + : null, + TopLevelSymbolLimitation = lang == "csharp" && !csharpTopLevelContractCurrent + ? "This index predates C# top-level synthetic symbols; reindex with the current cdidx binary." + : null, Symbols = symbols, }; } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCSharpDocumentationLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCSharpDocumentationLines.cs index 099237f4a..7c8bab2a8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCSharpDocumentationLines.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCSharpDocumentationLines.cs @@ -1,3 +1,5 @@ +using CodeIndex.Models; + namespace CodeIndex.Indexer; public static partial class ReferenceExtractor @@ -53,6 +55,7 @@ private static void EmitCoreCSharpDocCrefReferences( line.LineNumber, sameLineDeclarationStartColumn); if (docContainer == null + || docContainer.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind || (docContainer.StartLine != line.LineNumber && !CanAttachCSharpXmlDocCommentToNextDeclaration( innermostContainer, diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs new file mode 100644 index 000000000..ca36c5531 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs @@ -0,0 +1,170 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static void AddCSharpTopLevelScopeSymbol( + long fileId, + string[] lines, + string[] structuralLines, + List symbols) + { + if (structuralLines.Length == 0) + return; + + // This runs after AssignContainers. Declaration ranges can therefore be excluded + // without making source-declared local functions children of the synthetic scope. + // AssignContainers 後に実行し、宣言 range を除外しつつ source-declared local + // function の親を synthetic scope に変更しない。 + var declarationCoveredLines = BuildCSharpTopLevelDeclarationCoverage( + structuralLines.Length, + symbols); + var firstExecutableLine = 0; + var lastExecutableLine = 0; + var inUsingDirective = false; + var attributeBracketDepth = 0; + + for (var lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) + { + if (declarationCoveredLines[lineIndex]) + continue; + if (IsCSharpLineCommentOnly(lines[lineIndex])) + continue; + + var line = structuralLines[lineIndex].Trim(); + if (line.Length == 0) + continue; + + if (attributeBracketDepth > 0) + { + attributeBracketDepth += CountCSharpBracketDelta(line); + continue; + } + + if (IsCSharpFileScopeAttributeStart(line)) + { + attributeBracketDepth = Math.Max(0, CountCSharpBracketDelta(line)); + continue; + } + + if (inUsingDirective) + { + if (line.Contains(';', StringComparison.Ordinal)) + inUsingDirective = false; + continue; + } + + if (IsCSharpNonExecutableFileScopeLine(line, out var startsMultilineUsingDirective)) + { + inUsingDirective = startsMultilineUsingDirective; + continue; + } + + var lineNumber = lineIndex + 1; + if (firstExecutableLine == 0) + firstExecutableLine = lineNumber; + lastExecutableLine = lineNumber; + } + + if (firstExecutableLine == 0) + return; + + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + SubKind = SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind, + Name = SyntheticSymbolIdentity.CSharpTopLevelScopeName, + Line = firstExecutableLine, + StartLine = firstExecutableLine, + EndLine = lastExecutableLine, + BodyStartLine = firstExecutableLine, + BodyEndLine = lastExecutableLine, + Signature = SyntheticSymbolIdentity.CSharpTopLevelScopeName, + }); + } + + private static bool[] BuildCSharpTopLevelDeclarationCoverage( + int lineCount, + IReadOnlyList symbols) + { + var covered = new bool[lineCount]; + foreach (var symbol in symbols) + { + if (!IsCSharpFileScopeDeclarationForTopLevelDetection(symbol)) + continue; + + var startLine = Math.Clamp(symbol.StartLine > 0 ? symbol.StartLine : symbol.Line, 1, lineCount); + var endLine = Math.Clamp(symbol.EndLine >= startLine ? symbol.EndLine : startLine, startLine, lineCount); + for (var lineNumber = startLine; lineNumber <= endLine; lineNumber++) + covered[lineNumber - 1] = true; + } + + return covered; + } + + private static bool IsCSharpFileScopeDeclarationForTopLevelDetection(SymbolRecord symbol) + { + if (symbol.Kind is "namespace" + or "class" + or "struct" + or "interface" + or "enum" + or "record" + or "delegate") + { + return true; + } + + if (symbol.ContainerName != null) + return false; + + return symbol.Kind is "function" + or "test.method" + or "constructor" + or "operator"; + } + + private static bool IsCSharpNonExecutableFileScopeLine( + string line, + out bool startsMultilineUsingDirective) + { + startsMultilineUsingDirective = false; + if (line.StartsWith('#')) + return true; + if (line.StartsWith("extern alias ", StringComparison.Ordinal)) + return true; + if (line.StartsWith("global using ", StringComparison.Ordinal)) + { + startsMultilineUsingDirective = !line.Contains(';', StringComparison.Ordinal); + return true; + } + if (!line.StartsWith("using ", StringComparison.Ordinal)) + return false; + if (line.StartsWith("using var ", StringComparison.Ordinal) + || line.StartsWith("using (", StringComparison.Ordinal)) + { + return false; + } + + startsMultilineUsingDirective = !line.Contains(';', StringComparison.Ordinal); + return true; + } + + private static bool IsCSharpFileScopeAttributeStart(string line) + => line.StartsWith('['); + + private static int CountCSharpBracketDelta(string line) + { + var delta = 0; + foreach (var ch in line) + { + if (ch == '[') + delta++; + else if (ch == ']') + delta--; + } + return delta; + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index f167cfd80..ef15cd33c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -6,7 +6,7 @@ public static partial class SymbolExtractor public const int ExpandedLanguageContractVersion = 2; public const int YamlContractVersion = 3; public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 12; + public const int CSharpContractVersion = 13; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 8041cea89..6b86b0d6f 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -677,7 +677,7 @@ private static void AddScriptScopeSymbol(long fileId, string[] lines, List? getCSharpLineStartStates, @@ -792,7 +793,10 @@ private static void FinalizePatternSymbols( if (lang is "shell" or "powershell") AddScriptScopeSymbol(fileId, lines, symbols); if (lang == "csharp") + { + AddCSharpTopLevelScopeSymbol(fileId, lines, structuralLines, symbols); NormalizeCSharpImplicitPartialConstructorReturnTypes(symbols); + } if (lang == "go") { AssignGoMethodReceiverContainers(symbols); diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternExtraction.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternExtraction.cs index b379d56c6..ddae09dae 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternExtraction.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternExtraction.cs @@ -278,6 +278,7 @@ private static void CompletePatternExtraction(PatternExtractionContext context) filePath, projectRoot, lines, + structuralLines, symbols, extractionState, getCSharpLineStartStates, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 54e9c9586..0141265ca 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -352,6 +352,9 @@ public class SymbolResult public string Kind { get; set; } = string.Empty; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SubKind { get; set; } + [JsonPropertyName("is_synthetic")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsSynthetic => SyntheticSymbolIdentity.IsSyntheticSubKind(SubKind) ? true : null; public string Name { get; set; } = string.Empty; public int Line { get; set; } public int StartLine { get; set; } @@ -2785,6 +2788,11 @@ public class SymbolCandidateSelector [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Lang { get; set; } public string Kind { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SubKind { get; set; } + [JsonPropertyName("is_synthetic")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsSynthetic => SyntheticSymbolIdentity.IsSyntheticSubKind(SubKind) ? true : null; } public class SymbolCandidateBundle @@ -2845,12 +2853,31 @@ public class OutlineResult public string? Lang { get; set; } public int TotalLines { get; set; } public int SymbolCount { get; set; } + [JsonPropertyName("top_level_symbol_support")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TopLevelSymbolSupport { get; set; } + [JsonPropertyName("top_level_symbol_limitation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TopLevelSymbolLimitation { get; set; } public List Symbols { get; set; } = []; } public class OutlineSymbol { + [JsonPropertyName("symbol_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SymbolId { get; set; } public string Kind { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SubKind { get; set; } + [JsonPropertyName("is_synthetic")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsSynthetic => SyntheticSymbolIdentity.IsSyntheticSubKind(SubKind) ? true : null; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Selector { get; set; } + [JsonPropertyName("qualified_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QualifiedName { get; set; } public string Name { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string Path { get; set; } = string.Empty; diff --git a/src/CodeIndex/Models/SyntheticSymbolIdentity.cs b/src/CodeIndex/Models/SyntheticSymbolIdentity.cs new file mode 100644 index 000000000..43bb2455b --- /dev/null +++ b/src/CodeIndex/Models/SyntheticSymbolIdentity.cs @@ -0,0 +1,18 @@ +namespace CodeIndex.Models; + +/// +/// Stable public identities for scopes synthesized by the index rather than declared in source. +/// source declaration ではなく index が合成する scope の安定した公開 identity。 +/// +public static class SyntheticSymbolIdentity +{ + public const string ScriptScopeSubKind = "script_scope"; + public const string CSharpTopLevelScopeSubKind = "top_level_scope"; + public const string CSharpTopLevelScopeName = ""; + + public static bool IsSyntheticSubKind(string? subKind) + => subKind is ScriptScopeSubKind or CSharpTopLevelScopeSubKind; + + public static string BuildFileQualifiedName(string path, string name) + => $"{path}::{name}"; +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index 1de7463a0..ce8742244 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -6232,7 +6232,7 @@ FROM symbol_references SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(12, SymbolExtractor.CSharpContractVersion); + Assert.Equal(13, SymbolExtractor.CSharpContractVersion); } finally { @@ -6498,7 +6498,7 @@ ORDER BY symbol_name SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(12, SymbolExtractor.CSharpContractVersion); + Assert.Equal(13, SymbolExtractor.CSharpContractVersion); } finally { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerIssue5164Tests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5164Tests.cs new file mode 100644 index 000000000..e10d54712 --- /dev/null +++ b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5164Tests.cs @@ -0,0 +1,109 @@ +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public partial class IndexCommandRunnerTests +{ + [Fact] + public void Run_FullAndIncrementalTopLevelAddChangeDeleteRenameAreDeterministic_Issue5164() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_top_level_incremental_5164"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var programPath = Path.Combine(projectRoot, "Program.cs"); + var addedPath = Path.Combine(projectRoot, "Added.cs"); + var renamedPath = Path.Combine(projectRoot, "Renamed.cs"); + try + { + File.WriteAllText(programPath, "using System;\nConsole.WriteLine(\"initial\");\n"); + + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + var initial = ReadTopLevelFactIssue5164(dbPath, "Program.cs"); + Assert.Equal((2, 2), (initial.StartLine, initial.EndLine)); + + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + Assert.Equal(initial, ReadTopLevelFactIssue5164(dbPath, "Program.cs")); + + File.WriteAllText( + programPath, + "using System;\nConsole.WriteLine(\"changed and longer\");\nConsole.WriteLine(\"again\");\n"); + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + var changed = ReadTopLevelFactIssue5164(dbPath, "Program.cs"); + Assert.Equal((2, 3), (changed.StartLine, changed.EndLine)); + Assert.NotEqual(initial, changed); + + File.WriteAllText(addedPath, "System.Console.WriteLine(\"added\");\n"); + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + Assert.Equal((1, 1), ReadTopLevelFactIssue5164(dbPath, "Added.cs").Span); + Assert.Equal(2, CountTopLevelFactsIssue5164(dbPath)); + + File.Move(addedPath, renamedPath); + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + Assert.Null(TryReadTopLevelFactIssue5164(dbPath, "Added.cs")); + Assert.Equal((1, 1), ReadTopLevelFactIssue5164(dbPath, "Renamed.cs").Span); + Assert.Equal(2, CountTopLevelFactsIssue5164(dbPath)); + + File.Delete(programPath); + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + Assert.Null(TryReadTopLevelFactIssue5164(dbPath, "Program.cs")); + var renamed = ReadTopLevelFactIssue5164(dbPath, "Renamed.cs"); + Assert.Equal((1, 1), renamed.Span); + Assert.Equal(1, CountTopLevelFactsIssue5164(dbPath)); + + AssertSuccessfulTopLevelIndexIssue5164(projectRoot); + Assert.Equal(renamed, ReadTopLevelFactIssue5164(dbPath, "Renamed.cs")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + private void AssertSuccessfulTopLevelIndexIssue5164(string projectRoot) + { + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("success", json.GetProperty("status").GetString()); + } + + private static TopLevelFact ReadTopLevelFactIssue5164(string dbPath, string path) + => TryReadTopLevelFactIssue5164(dbPath, path) + ?? throw new Xunit.Sdk.XunitException($"Expected one top-level symbol for '{path}'."); + + private static TopLevelFact? TryReadTopLevelFactIssue5164(string dbPath, string path) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var command = db.Connection.CreateCommand(); + command.CommandText = """ + SELECT s.id, s.start_line, s.end_line + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.path = @path AND s.sub_kind = @sub_kind + ORDER BY s.id; + """; + command.Parameters.AddWithValue("@path", path); + command.Parameters.AddWithValue("@sub_kind", SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + using var reader = command.ExecuteReader(); + if (!reader.Read()) + return null; + + var result = new TopLevelFact(reader.GetInt64(0), reader.GetInt32(1), reader.GetInt32(2)); + Assert.False(reader.Read(), $"Expected at most one top-level symbol for '{path}'."); + return result; + } + + private static int CountTopLevelFactsIssue5164(string dbPath) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var command = db.Connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM symbols WHERE sub_kind = @sub_kind"; + command.Parameters.AddWithValue("@sub_kind", SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + return Convert.ToInt32(command.ExecuteScalar()); + } + + private sealed record TopLevelFact(long SymbolId, int StartLine, int EndLine) + { + public (int StartLine, int EndLine) Span => (StartLine, EndLine); + } +} diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 0f504a0a0..e73310f8e 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -6824,6 +6824,34 @@ public void ToolsCall_Outline_ReturnsSymbols() Assert.Equal("App.Run", run["path"]!.GetValue()); } + [Fact] + public void ToolsCall_Outline_ReturnsActionableCSharpTopLevelScope_Issue5164() + { + InsertIndexedFile( + "src/top-level.cs", + "csharp", + "using System;\nConsole.WriteLine(\"mcp\");\n"); + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":5164,"method":"tools/call","params":{"name":"outline","arguments":{"path":"src/top-level.cs"}}}""")!; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + var structured = result["structuredContent"]!; + Assert.Equal("src/top-level.cs", structured["path"]!.GetValue()); + Assert.Equal("indexed", structured["top_level_symbol_support"]!.GetValue()); + var symbol = Assert.Single(structured["symbols"]!.AsArray().Where(candidate => + candidate!["name"]!.GetValue() == SyntheticSymbolIdentity.CSharpTopLevelScopeName))!; + Assert.Equal(SyntheticSymbolIdentity.CSharpTopLevelScopeName, symbol["name"]!.GetValue()); + Assert.Equal("function", symbol["kind"]!.GetValue()); + Assert.Equal(SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind, symbol["subKind"]!.GetValue()); + Assert.True(symbol["is_synthetic"]!.GetValue()); + Assert.Equal(2, symbol["startLine"]!.GetValue()); + Assert.Equal(2, symbol["endLine"]!.GetValue()); + Assert.Equal("src/top-level.cs::", symbol["qualified_name"]!.GetValue()); + Assert.StartsWith("id:", symbol["selector"]!.GetValue(), StringComparison.Ordinal); + } + [Fact] public void ToolsCall_Outline_NotFound_ReturnsError() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index bb751e35a..daf4b023b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -2999,7 +2999,7 @@ public void RunOutline_ProjectionValidationEmitsOneTerminalErrorAndPreservesVali Assert.Single(invalid.ParseError.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); foreach (var invalidValue in testCase.InvalidValues) Assert.Contains($"'{invalidValue}'", invalid.ParseError, StringComparison.Ordinal); - Assert.Contains("Use one or more of all, kind, name", invalid.ParseError, StringComparison.Ordinal); + Assert.Contains("Use one or more of all, symbol_id, kind, sub_kind, is_synthetic, selector, qualified_name, name", invalid.ParseError, StringComparison.Ordinal); Assert.Contains("aliases range, lines, body", invalid.ParseError, StringComparison.Ordinal); Assert.DoesNotContain("--outline-fields requires at least one field name", invalid.ParseError, StringComparison.Ordinal); } @@ -3140,7 +3140,7 @@ public void Process(int count, CancellationToken cancellationToken = default) { } [Fact] - public void RunOutline_Human_CSharpTopLevelStatementsFile_WritesHelpfulNote() + public void RunOutline_Human_CSharpTopLevelStatementsFile_ShowsSyntheticScope_Issue5164() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_csharp_toplevel_human"); try @@ -3183,8 +3183,9 @@ static int Sum(int[] values) Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("# src/program.cs", stdout); - Assert.Contains("Note: no type/namespace declarations found; this file likely uses C# top-level statements.", stderr); - Assert.Contains("Outline lists imports and local functions only; the executable body is not indexed as symbols.", stderr); + Assert.Contains("", stdout); + Assert.Contains("[synthetic function/top_level_scope, lines 4-24, selector id:", stdout); + Assert.Equal(string.Empty, stderr); } finally { @@ -3193,7 +3194,7 @@ static int Sum(int[] values) } [Fact] - public void RunOutline_Json_CSharpTopLevelStatementsFile_LeavesJsonContractUnchanged() + public void RunOutline_Json_CSharpTopLevelStatementsFile_ExposesSyntheticIdentity_Issue5164() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_csharp_toplevel_json"); try @@ -3237,7 +3238,14 @@ public void RunOutline_Json_CSharpTopLevelStatementsFile_LeavesJsonContractUncha Assert.Equal(string.Empty, stderr); Assert.Equal("src/program.cs", json.GetProperty("path").GetString()); Assert.Equal("csharp", json.GetProperty("lang").GetString()); - Assert.True(json.TryGetProperty("symbols", out _)); + Assert.Equal("indexed", json.GetProperty("top_level_symbol_support").GetString()); + var topLevel = Assert.Single(json.GetProperty("symbols").EnumerateArray().Where(symbol => + symbol.GetProperty("name").GetString() == SyntheticSymbolIdentity.CSharpTopLevelScopeName)); + Assert.Equal("function", topLevel.GetProperty("kind").GetString()); + Assert.Equal("top_level_scope", topLevel.GetProperty("sub_kind").GetString()); + Assert.True(topLevel.GetProperty("is_synthetic").GetBoolean()); + Assert.StartsWith("id:", topLevel.GetProperty("selector").GetString(), StringComparison.Ordinal); + Assert.Equal("src/program.cs::", topLevel.GetProperty("qualified_name").GetString()); } finally { @@ -3280,7 +3288,7 @@ public void RunOutline_Json_AcceptsAbsolutePathWithExplicitDbOutsideProjectRoot( } [Fact] - public void RunOutline_Human_CSharpStatementOnlyTopLevelFile_WritesHelpfulNote() + public void RunOutline_Human_CSharpStatementOnlyTopLevelFile_ShowsSyntheticScope_Issue5164() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_csharp_statement_only_human"); try @@ -3319,8 +3327,8 @@ public void RunOutline_Human_CSharpStatementOnlyTopLevelFile_WritesHelpfulNote() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("# src/program.cs", stdout); - Assert.Contains("Note: no type/namespace declarations found; this file likely uses C# top-level statements.", stderr); - Assert.Contains("Outline lists imports and local functions only; the executable body is not indexed as symbols.", stderr); + Assert.Contains("[synthetic function/top_level_scope", stdout); + Assert.Equal(string.Empty, stderr); } finally { @@ -3329,7 +3337,7 @@ public void RunOutline_Human_CSharpStatementOnlyTopLevelFile_WritesHelpfulNote() } [Fact] - public void RunOutline_Human_CSharpUsingVarTopLevelFile_WritesHelpfulNote() + public void RunOutline_Human_CSharpUsingVarTopLevelFile_ShowsSyntheticScope_Issue5164() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_csharp_using_var_human"); try @@ -3369,8 +3377,8 @@ public void RunOutline_Human_CSharpUsingVarTopLevelFile_WritesHelpfulNote() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("# src/program.cs", stdout); - Assert.Contains("Note: no type/namespace declarations found; this file likely uses C# top-level statements.", stderr); - Assert.Contains("Outline lists imports and local functions only; the executable body is not indexed as symbols.", stderr); + Assert.Contains("[synthetic function/top_level_scope", stdout); + Assert.Equal(string.Empty, stderr); } finally { @@ -3379,7 +3387,7 @@ public void RunOutline_Human_CSharpUsingVarTopLevelFile_WritesHelpfulNote() } [Fact] - public void RunOutline_Human_CSharpUsingStatementTopLevelFile_WritesHelpfulNote() + public void RunOutline_Human_CSharpUsingStatementTopLevelFile_ShowsSyntheticScope_Issue5164() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_csharp_using_statement_human"); try @@ -3421,8 +3429,8 @@ public void RunOutline_Human_CSharpUsingStatementTopLevelFile_WritesHelpfulNote( Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("# src/program.cs", stdout); - Assert.Contains("Note: no type/namespace declarations found; this file likely uses C# top-level statements.", stderr); - Assert.Contains("Outline lists imports and local functions only; the executable body is not indexed as symbols.", stderr); + Assert.Contains("[synthetic function/top_level_scope", stdout); + Assert.Equal(string.Empty, stderr); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs new file mode 100644 index 000000000..98dec4d60 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs @@ -0,0 +1,251 @@ +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public sealed class QueryCommandRunnerIssue5164Tests +{ + [Fact] + public void TopLevelSelectors_RoundTripCoordinateAndCalleeIdentityWithoutCrossFileCollisions_Issue5164() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_top_level_selector_5164"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFiles( + dbPath, + [ + new TestProjectHelper.IndexedFileFixture( + "src/First.cs", + "csharp", + """ + using System.Text; + using CodeIndex.Cli; + + // On Windows the console defaults to the OEM code page. + // Keep Unicode output deterministic. + // 日本語の出力も UTF-8 に揃える。 + Console.OutputEncoding = Encoding.UTF8; + ConsoleUi.EnsureConsoleWritersSynchronized(); + RuntimeSafety.Configure(); + return ProgramRunner.Run(args); + """), + new TestProjectHelper.IndexedFileFixture( + "src/Second.cs", + "csharp", + "using System;\nConsole.WriteLine(\"second\");\nTarget.Run();\n"), + new TestProjectHelper.IndexedFileFixture( + "src/Target.cs", + "csharp", + "public static class Target { public static void Run() { } }\n"), + ]); + MarkCurrentContracts(dbPath); + + var firstOutline = RunOutline(dbPath, "src/First.cs"); + var secondOutline = RunOutline(dbPath, "src/Second.cs"); + var firstTopLevel = GetTopLevelSymbol(firstOutline.RootElement); + var secondTopLevel = GetTopLevelSymbol(secondOutline.RootElement); + var firstSelector = firstTopLevel.GetProperty("selector").GetString()!; + var secondSelector = secondTopLevel.GetProperty("selector").GetString()!; + + Assert.NotEqual(firstSelector, secondSelector); + Assert.Equal("src/First.cs::", firstTopLevel.GetProperty("qualified_name").GetString()); + Assert.Equal(7, firstTopLevel.GetProperty("start_line").GetInt32()); + Assert.Equal(10, firstTopLevel.GetProperty("end_line").GetInt32()); + + foreach (var line in new[] { 7, 8, 9, 10 }) + { + var (inspectExitCode, inspectStdout, inspectStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--path", "src/First.cs", "--line", line.ToString(), "--db", dbPath, "--json"], + QueryCommandTestSupport.JsonOptions)); + using var inspect = QueryCommandTestSupport.ParseJsonOutput(inspectStdout); + var definition = Assert.Single(inspect.RootElement.GetProperty("definitions").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, inspectExitCode); + Assert.Equal(string.Empty, inspectStderr); + var bundle = inspect.RootElement.GetProperty("candidate_bundles")[0]; + Assert.Equal(firstSelector, bundle + .GetProperty("selector") + .GetProperty("selector") + .GetString()); + Assert.Equal(SyntheticSymbolIdentity.CSharpTopLevelScopeName, definition.GetProperty("name").GetString()); + Assert.True(definition.GetProperty("is_synthetic").GetBoolean()); + var calleeNames = bundle.GetProperty("callees") + .EnumerateArray() + .Select(callee => callee.GetProperty("callee_name").GetString()) + .ToArray(); + Assert.Contains("EnsureConsoleWritersSynchronized", calleeNames); + Assert.Contains("Configure", calleeNames); + Assert.Contains("Run", calleeNames); + } + + var (calleesExitCode, calleesStdout, calleesStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunCallees( + [firstSelector, "--path", "src/First.cs", "--db", dbPath, "--json"], + QueryCommandTestSupport.JsonOptions)); + using var calleeRows = new JsonDocumentCollection(QueryCommandTestSupport.ParseJsonLines(calleesStdout)); + Assert.Equal(CommandExitCodes.Success, calleesExitCode); + Assert.Equal(string.Empty, calleesStderr); + Assert.All(calleeRows.Documents, row => + Assert.Equal("src/First.cs", row.RootElement.GetProperty("path").GetString())); + Assert.Equal( + new[] { "Configure", "EnsureConsoleWritersSynchronized", "Run" }, + calleeRows.Documents + .Select(row => row.RootElement.GetProperty("callee_name").GetString()) + .Order(StringComparer.Ordinal) + .ToArray()); + + var (countExitCode, countStdout, countStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunCallees( + [firstSelector, "--path", "src/First.cs", "--db", dbPath, "--count", "--json"], + QueryCommandTestSupport.JsonOptions)); + using var count = QueryCommandTestSupport.ParseJsonOutput(countStdout); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + Assert.Equal(3, count.RootElement.GetProperty("count").GetInt32()); + + var (callersExitCode, callersStdout, callersStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunCallers( + ["Run", "--db", dbPath, "--json", "--exact-name"], + QueryCommandTestSupport.JsonOptions)); + using var callerRows = new JsonDocumentCollection(QueryCommandTestSupport.ParseJsonLines(callersStdout)); + Assert.Equal(CommandExitCodes.Success, callersExitCode); + Assert.Equal(string.Empty, callersStderr); + var topLevelCallers = callerRows.Documents + .Where(row => row.RootElement.GetProperty("caller_name").GetString() == SyntheticSymbolIdentity.CSharpTopLevelScopeName) + .Select(row => row.RootElement.GetProperty("path").GetString()) + .Order(StringComparer.Ordinal) + .ToArray(); + Assert.Equal(new[] { "src/First.cs", "src/Second.cs" }, topLevelCallers); + + var (compactExitCode, compactStdout, compactStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunOutline( + ["src/First.cs", "--db", dbPath, "--compact"], + QueryCommandTestSupport.JsonOptions)); + using var compact = QueryCommandTestSupport.ParseJsonOutput(compactStdout); + var compactTopLevel = GetTopLevelSymbol(compact.RootElement); + Assert.Equal(CommandExitCodes.Success, compactExitCode); + Assert.Equal(string.Empty, compactStderr); + Assert.Equal("src/First.cs", compact.RootElement.GetProperty("path").GetString()); + Assert.Equal("function", compactTopLevel.GetProperty("kind").GetString()); + Assert.Equal(7, compactTopLevel.GetProperty("start_line").GetInt32()); + Assert.Equal(10, compactTopLevel.GetProperty("end_line").GetInt32()); + Assert.True(compactTopLevel.GetProperty("is_synthetic").GetBoolean()); + Assert.Equal(firstSelector, compactTopLevel.GetProperty("selector").GetString()); + Assert.Equal("src/First.cs::", compactTopLevel.GetProperty("qualified_name").GetString()); + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/GenerationChange.cs", + "csharp", + "public sealed class GenerationChange { }\n"); + var (staleExitCode, staleStdout, staleStderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunCallees( + [firstSelector, "--db", dbPath, "--json"], + QueryCommandTestSupport.JsonOptions)); + using var stale = QueryCommandTestSupport.ParseJsonOutput(staleStdout); + Assert.Equal(CommandExitCodes.NotFound, staleExitCode); + Assert.Equal(string.Empty, staleStderr); + Assert.Equal(CommandErrorCodes.QueryNotFound, stale.RootElement.GetProperty("error_code").GetString()); + Assert.Contains("stale", stale.RootElement.GetProperty("message").GetString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Outline_StaleOrMissingCSharpExtractorContractReportsTypedReindexLimitation_Issue5164( + bool hasLegacyExtractorVersion) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_top_level_stale_5164"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Legacy.cs", + "csharp", + "using System;\nConsole.WriteLine(\"legacy\");\n"); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + var extractorVersionKey = DbContext.GetSymbolExtractorVersionMetaKey("csharp"); + if (hasLegacyExtractorVersion) + { + writer.SetMeta( + extractorVersionKey, + (SymbolExtractor.CSharpContractVersion - 1).ToString()); + } + else + { + using var deleteMeta = db.Connection.CreateCommand(); + deleteMeta.CommandText = "DELETE FROM codeindex_meta WHERE key = @key"; + deleteMeta.Parameters.AddWithValue("@key", extractorVersionKey); + Assert.Equal(1, deleteMeta.ExecuteNonQuery()); + } + using var command = db.Connection.CreateCommand(); + command.CommandText = "DELETE FROM symbols WHERE sub_kind = @sub_kind"; + command.Parameters.AddWithValue("@sub_kind", SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + Assert.Equal(1, command.ExecuteNonQuery()); + } + + var (exitCode, stdout, stderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunOutline( + ["src/Legacy.cs", "--db", dbPath, "--json"], + QueryCommandTestSupport.JsonOptions)); + using var document = QueryCommandTestSupport.ParseJsonOutput(stdout); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("reindex_required", document.RootElement.GetProperty("top_level_symbol_support").GetString()); + Assert.Contains("reindex", document.RootElement.GetProperty("top_level_symbol_limitation").GetString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static JsonDocument RunOutline(string dbPath, string path) + { + var (exitCode, stdout, stderr) = QueryCommandTestSupport.CaptureConsole(() => + QueryCommandRunner.RunOutline( + [path, "--db", dbPath, "--json"], + QueryCommandTestSupport.JsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + return QueryCommandTestSupport.ParseJsonOutput(stdout); + } + + private static JsonElement GetTopLevelSymbol(JsonElement outline) + => Assert.Single(outline.GetProperty("symbols").EnumerateArray().Where(symbol => + symbol.GetProperty("name").GetString() == SyntheticSymbolIdentity.CSharpTopLevelScopeName)); + + private static void MarkCurrentContracts(string dbPath) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + writer.MarkReferenceIdentityContractReady(); + writer.StampSymbolExtractorVersions(["csharp"]); + } + + private sealed class JsonDocumentCollection(List documents) : IDisposable + { + public List Documents { get; } = documents; + + public void Dispose() + { + foreach (var document in Documents) + document.Dispose(); + } + } +} diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 64794fd91..bb385935b 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -11,6 +11,96 @@ namespace CodeIndex.Tests; public partial class SymbolExtractorTests { + [Fact] + public void Extract_CSharpTopLevelProgram_AddsSyntheticScopeAndAttributesCalls_Issue5164() + { + const string content = """ + using System.Text; + using CodeIndex.Cli; + + // On Windows the console defaults to the OEM code page. + // Keep Unicode output deterministic. + // 日本語の出力も UTF-8 に揃える。 + Console.OutputEncoding = Encoding.UTF8; + ConsoleUi.EnsureConsoleWritersSynchronized(); + RuntimeSafety.Configure(); + return ProgramRunner.Run(args); + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var topLevel = Assert.Single(symbols, symbol => + symbol.Kind == "function" + && symbol.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind + && symbol.Name == SyntheticSymbolIdentity.CSharpTopLevelScopeName); + Assert.Equal(7, topLevel.Line); + Assert.Equal(7, topLevel.StartLine); + Assert.Equal(10, topLevel.EndLine); + Assert.Equal(7, topLevel.BodyStartLine); + Assert.Equal(10, topLevel.BodyEndLine); + + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + foreach (var callName in new[] { "EnsureConsoleWritersSynchronized", "Configure", "Run" }) + { + var call = Assert.Single(references, reference => + reference.ReferenceKind == "call" + && reference.SymbolName == callName); + Assert.Equal("function", call.ContainerKind); + Assert.Equal(SyntheticSymbolIdentity.CSharpTopLevelScopeName, call.ContainerName); + } + } + + [Fact] + public void Extract_CSharpTopLevelProgram_LocalFunctionKeepsDeclaredScope_Issue5164() + { + const string content = """ + using System; + Console.WriteLine("before"); + void Helper() { Console.WriteLine("inside"); } + Helper(); + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var topLevel = Assert.Single(symbols, symbol => + symbol.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + Assert.Equal(2, topLevel.StartLine); + Assert.Equal(4, topLevel.EndLine); + + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + var nestedCall = Assert.Single(references, reference => + reference.ReferenceKind == "call" + && reference.SymbolName == "WriteLine" + && reference.Line == 3); + Assert.Equal("Helper", nestedCall.ContainerName); + var topLevelCall = Assert.Single(references, reference => + reference.ReferenceKind == "call" + && reference.SymbolName == "Helper"); + Assert.Equal(SyntheticSymbolIdentity.CSharpTopLevelScopeName, topLevelCall.ContainerName); + } + + [Fact] + public void Extract_CSharpNonExecutableCompilationUnits_DoNotAddSyntheticScope_Issue5164() + { + var cases = new Dictionary + { + ["empty"] = string.Empty, + ["comments"] = "// comment\n/* block comment */\n", + ["directives"] = "#nullable enable\n#define FEATURE\n", + ["global-usings"] = "global using System;\nglobal using Text = System.Text;\n", + ["assembly-attributes"] = "[assembly:\n System.CLSCompliant(true)]\n[module: System.Runtime.CompilerServices.SkipLocalsInit]\n", + ["type"] = "public sealed class App { public static void Main() { Run(); } }\n", + ["attributed-type"] = "[System.Obsolete]\npublic sealed class AttributedApp { }\n", + ["file-namespace"] = "namespace Example;\npublic sealed class App { }\n", + }; + + foreach (var (name, content) in cases) + { + var symbols = SymbolExtractor.Extract(1, "csharp", content); + Assert.DoesNotContain( + symbols, + symbol => symbol.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + } + } + [Fact] public void SanitizeCSharpLinesForCrossLineScan_PlainLineReusesSourceText() { diff --git a/tests/CodeIndex.Tests/TestProjectHelper.cs b/tests/CodeIndex.Tests/TestProjectHelper.cs index 498e1d56b..b5eb153ca 100644 --- a/tests/CodeIndex.Tests/TestProjectHelper.cs +++ b/tests/CodeIndex.Tests/TestProjectHelper.cs @@ -354,6 +354,7 @@ private static bool InsertIndexedFile( { writer.InsertReferences(references); } + writer.StampSymbolExtractorVersions([lang]); return references.Count > 0; } From 1d871c774fe30994211ec9f8d24c3cae59574a73 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 24 Aug 2026 15:44:33 +0900 Subject: [PATCH 2/5] Address top-level symbol review findings (#5164) --- DEVELOPER_GUIDE.md | 20 ++- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 10 +- changelog.d/unreleased/5164.added.md | 7 +- .../Database/DbSymbolReader.Analysis.cs | 2 +- ...DbSymbolReader.UnusedSymbols.Candidates.cs | 7 ++ .../Symbols/SymbolExtractor.CSharpTopLevel.cs | 31 ++++- src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 115 +++++++++++++++--- .../DbReaderUnusedCandidateQueryTests.cs | 40 ++++++ .../McpServerToolsCallTests.cs | 50 +++++++- .../QueryCommandRunnerIssue5164Tests.cs | 41 +++++++ .../SymbolExtractorCSharpTests.cs | 37 ++++++ 12 files changed, 328 insertions(+), 36 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ba0ce733e..4b38cfbfd 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1151,8 +1151,10 @@ resolved by symbol id, so identical top-level programs in different files do not share callee identity. Detection runs after container assignment. It excludes declaration-covered -ranges, imports, comments, directives, and assembly/module metadata, then uses -the first and last uncovered executable lines as both source and body bounds. +ranges, imports, comments, directives, and assembly/module metadata, recognizes +both `using var` and explicitly typed `using Type value = ...` declarations as +executable rather than import directives, then uses the first and last uncovered +executable lines as both source and body bounds. A top-level local function remains source-declared and containerless; its own narrower span owns references inside the function, while a synthetic range may cross it when executable statements occur on both sides. Reference extraction @@ -1160,7 +1162,10 @@ uses the synthetic symbol's persisted id for otherwise containerless calls in that body. The synthetic scope is not a documented declaration, so an XML-doc comment before a top-level statement does not attach to it. `outline`, coordinate `inspect`, and identity-scoped `callees` must -therefore navigate the same row. If a stored C# extractor version predates this +therefore navigate the same row; both CLI and MCP `callees` resolve its selector +by persisted symbol id. Unused-symbol list and count queries exclude this +synthetic entry point because it is executable infrastructure, not removable +dead code. If a stored C# extractor version predates this contract or is missing, and no synthetic row is available, outline reports `top_level_symbol_support=reindex_required` plus a typed limitation instead of claiming support. @@ -5126,14 +5131,17 @@ index generation だけで有効で、symbol id により解決するため、 top-level program が callee identity を共有することはありません。 検出は container assignment の後に実行します。declaration が覆う range、import、comment、 -directive、assembly/module metadata を除外し、残った最初と最後の実行可能行を source/body -両方の境界にします。top-level local function は container を持たない source-declared symbol +directive、assembly/module metadata を除外し、`using var` と明示型の +`using Type value = ...` declaration の両方を import directive ではなく実行可能コードとして認識し、 +残った最初と最後の実行可能行を source/body 両方の境界にします。top-level local function は container を持たない source-declared symbol のままで、その function 内の reference はより狭い自身の span が所有します。両側に実行 statement がある場合、synthetic range は local function をまたぐことがあります。reference extraction は、この body 内で従来 container を持たなかった call に synthetic symbol の永続化 id を使います。synthetic scope は documented declaration ではないため、top-level statement 直前の XML-doc comment を自身へ結び付けません。そのため `outline`、座標指定 `inspect`、identity-scoped `callees` は同じ row を -navigate しなければなりません。保存済み C# extractor version がこの契約より古いか欠落しており、 +navigate しなければならず、CLI / MCP の両 `callees` が selector を永続化済み symbol id で解決します。 +unused-symbol の list / count query は、この synthetic entry point が削除可能な dead code ではなく実行基盤なので除外します。 +保存済み C# extractor version がこの契約より古いか欠落しており、 synthetic row も無い場合、outline は対応済みと見せず、`top_level_symbol_support=reindex_required` と型付きの limitation を返します。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index a0bb68da1..678eb99bc 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -561,7 +561,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result do not allocate production-sized 50k/20k inputs to test these contracts (#4620). - Broad extractor `*CompletesWithinPracticalBudget` runaway guards run only on the primary `net8.0` test target. Keep focused functional extractor tests cross-target, but do not duplicate the large-fixture budget guards across every target framework unless the guard is specifically proving a target-framework-specific contract. - C# reflection-name extraction coverage keeps literal, constant-concatenation, dynamic, comment, and string-decoy cases in one source fixture so those parser boundaries share one symbol/reference pass. -- C# top-level synthetic-scope coverage (#5164) keeps the 10-line entry-point, local-function ownership, and all import/metadata/comment/directive/type/explicit-`Main` negatives in focused extractor fixtures. One CLI reader fixture owns coordinate selection, cross-file selector isolation, caller/callee round-tripping, compact output, and stale-contract guidance; one MCP assertion owns transport naming parity. Keep full plus incremental no-op/change/add/rename/delete/no-op persistence in `IndexCommandRunnerIssue5164Tests.cs` so lifecycle coverage uses real index reconciliation instead of direct database fixtures. +- C# top-level synthetic-scope coverage (#5164) keeps the 10-line entry-point, local-function ownership, typed using declarations, bounded extraction, and all import/metadata/comment/directive/type/explicit-`Main` negatives in focused extractor fixtures. CLI reader fixtures own coordinate selection, cross-file selector isolation, caller/callee round-tripping, script-scope identity parity, compact output, stale-contract guidance, and unused list/count exclusion; the MCP assertion owns selector-to-`callees` round-tripping as well as transport naming parity. Keep full plus incremental no-op/change/add/rename/delete/no-op persistence in `IndexCommandRunnerIssue5164Tests.cs` so lifecycle coverage uses real index reconciliation instead of direct database fixtures. - C# BOM extraction keeps a simple leading-BOM import fixture plus one mixed-newline fixture that simultaneously covers leading and mid-file BOM handling across CRLF, bare CR, and LF boundaries; do not repeat separate extraction passes for newline subsets already present in the mixed fixture. - C# lambda-capture coverage keeps positive enclosing-local capture, parameter shadowing, and same-named-method isolation in one source fixture; a single capture assertion proves the negative regions did not leak. - C# static-lambda declaration regression coverage keeps stateful, typed/untyped, explicit-return, both async-modifier orders, Unicode/escaped-identifier, multiline, nested, and argument-position forms in one extractor pass. Preserve real static members/local functions, including generic, constructor, explicit-interface, and verbatim-type-name forms, and an assigned-lambda range/container assertion in that fixture, plus one CLI `symbols` corpus fixture for phantom-name checks (#4830). @@ -1686,7 +1686,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は高負荷な C# reference extraction に対する広めの runaway guard です。C# warmup は `CI=true` かつ `net8.0` test assembly の場合だけ、各 guard が fixture 構築と stopwatch 計測より前に呼ぶ `Lazy` gate により test process ごとに1回実行します。どちらの guard も含まない shard は extraction と強制 GC の固定 warmup cost を負いません。module initializer では hook discovery の delay、persistent worker PID/thread、persistent descendant PID/process の environment 処理をこの順序のまま eager に維持してください。warmup は最後に強制 GC を行うため、この2 guard だけを専用 non-parallel collection に保ち、巨大な `ReferenceExtractorTests` partial class 全体を non-parallel にしないでください。budget は benchmark 閾値ではなく回帰 tripwire として扱い、焦点を絞った最適化根拠がない限り noisy CI に十分な余裕を残してください。 - extractor の広い `*CompletesWithinPracticalBudget` runaway guard は primary の `net8.0` test target だけで実行します。focused な extractor 機能テストは cross-target のまま維持しますが、その guard が target-framework 固有の契約を証明する場合を除き、大規模 fixture の budget guard をすべての target framework で重複実行しないでください。 - C# reflection-name 抽出 coverage は、literal、定数連結、dynamic、comment、string decoy を1つの source fixture にまとめ、これらの parser boundary で1回の symbol/reference pass を共有します。 -- C# top-level synthetic scope の coverage (#5164) は、10行の entry point、local-function ownership、import / metadata / comment / directive / type / explicit `Main` の全 negative を focused extractor fixture に維持します。1つの CLI reader fixture が座標選択、file 間の selector 分離、caller/callee round-trip、compact 出力、stale-contract guidance を担当し、MCP の1 assertion が transport の命名 parity を担当します。full と incremental の no-op / change / add / rename / delete / no-op 永続化は `IndexCommandRunnerIssue5164Tests.cs` に維持し、direct database fixture ではなく実際の index reconciliation で lifecycle を検証してください。 +- C# top-level synthetic scope の coverage (#5164) は、10行の entry point、local-function ownership、typed using declaration、bounded extraction、import / metadata / comment / directive / type / explicit `Main` の全 negative を focused extractor fixture に維持します。CLI reader fixture は座標選択、file 間の selector 分離、caller/callee round-trip、script-scope identity parity、compact 出力、stale-contract guidance、unused list/count からの除外を担当し、MCP の assertion は transport の命名 parity に加えて selector から `callees` への round-trip を担当します。full と incremental の no-op / change / add / rename / delete / no-op 永続化は `IndexCommandRunnerIssue5164Tests.cs` に維持し、direct database fixture ではなく実際の index reconciliation で lifecycle を検証してください。 - C# BOM 抽出は、単純な先頭 BOM import fixture と、CRLF・bare CR・LF 境界で先頭/mid-file BOM を同時に扱う1つの混在改行 fixture を維持します。混在 fixture に含まれる改行 subset ごとに抽出 pass を重複させないでください。 - C# lambda capture coverage は、外側 local の正例、parameter shadowing、同名 method 間の分離を1つの source fixture にまとめます。capture が1件だけである assertion により、negative region からの漏れも同時に検証します。 - C# static lambda の宣言回帰 coverage は、stateful、型あり/型なし、明示的戻り値型、両方の async modifier 順、Unicode/escape 識別子、複数行、入れ子、引数位置の各形式を1回の extractor pass にまとめます。同じ fixture で generic、constructor、明示的 interface、verbatim 型名を含む本物の static member / local function と、代入済み lambda の range / container assertion を維持し、phantom 名の確認には CLI `symbols` corpus fixture を1つ追加します(#4830)。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 175be5106..7eef04db4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2057,8 +2057,9 @@ C# files with executable top-level statements expose a file-scoped synthetic `function` named ``. JSON and MCP identify it with `sub_kind: "top_level_scope"`, `is_synthetic: true`, a `::` `qualified_name`, and a snapshot-bound `selector` that can -be passed directly to `callees` (optionally with `--path`) and is distinct for -each file. Coordinate `inspect --path --line ` selects the same +be passed directly to CLI `callees` as its positional query or to MCP +`callees.query` (optionally scoped with CLI `--path` or MCP `path`) and is +distinct for each file. Coordinate `inspect --path --line ` selects the same node on executable lines. Its span runs from the first through the last executable top-level line while excluding imports, comments, directives, and compilation metadata where possible. A top-level local function remains a @@ -5676,8 +5677,9 @@ cdidx outline src/CodeIndex/Cli/QueryCommandRunner.cs --compact --kind function file-scoped synthetic `function` を公開します。JSON / MCP では `sub_kind: "top_level_scope"`、`is_synthetic: true`、 `::` 形式の `qualified_name`、snapshot に束縛された -`selector` で識別します。selector は file ごとに異なり、`callees` の positional -値として直接渡せます(必要に応じて `--path` も指定できます)。実行行に対する +`selector` で識別します。selector は file ごとに異なり、CLI `callees` の positional +query または MCP `callees.query` として直接渡せます(必要に応じて CLI `--path` または +MCP `path` で scope を限定できます)。実行行に対する `inspect --path --line ` も同じ node を選択します。span は import、 comment、directive、compilation metadata を可能な範囲で除外し、最初から最後の 実行可能 top-level 行までを覆います。top-level local function は通常の source-declared diff --git a/changelog.d/unreleased/5164.added.md b/changelog.d/unreleased/5164.added.md index 543f70904..750d6cf40 100644 --- a/changelog.d/unreleased/5164.added.md +++ b/changelog.d/unreleased/5164.added.md @@ -5,7 +5,10 @@ issues: affected: - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs - src/CodeIndex/Database/DbSymbolReader.Outline.cs + - src/CodeIndex/Database/DbSymbolReader.Analysis.cs + - src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.Candidates.cs - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs - USER_GUIDE.md - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -13,8 +16,8 @@ affected: ## English -- **C# top-level program bodies are now actionable synthetic symbols (#5164)** — `outline`, coordinate `inspect`, CLI JSON/compact output, and MCP expose a file-qualified `` function with an explicit synthetic marker and snapshot selector; passing that selector to `callees` now round-trips the same file-scoped caller identity without colliding across files, while stale indexes request a reindex. +- **C# top-level program bodies are now actionable synthetic symbols (#5164)** — `outline`, coordinate `inspect`, CLI JSON/compact output, and MCP expose a file-qualified `` function with an explicit synthetic marker and snapshot selector; passing that selector to CLI or MCP `callees` round-trips the same file-scoped caller identity without colliding across files, typed using declarations remain executable, synthetic entry points stay out of unused-code results, and stale indexes request a reindex. ## 日本語 -- **C# top-level program body を操作可能な synthetic symbol として公開するようになりました (#5164)** — `outline`、座標指定 `inspect`、CLI JSON/compact 出力、MCP は、明示的な synthetic marker と snapshot selector を持つ file-qualified な `` function を公開します。その selector を `callees` に渡すと file 間で衝突せず同じ file-scoped caller identity を往復でき、stale index には reindex を案内します。 +- **C# top-level program body を操作可能な synthetic symbol として公開するようになりました (#5164)** — `outline`、座標指定 `inspect`、CLI JSON/compact 出力、MCP は、明示的な synthetic marker と snapshot selector を持つ file-qualified な `` function を公開します。その selector は CLI / MCP の `callees` で file 間の衝突なく同じ file-scoped caller identity を往復でき、typed using declaration は実行可能コードとして維持され、synthetic entry point は unused-code 結果から除外され、stale index には reindex を案内します。 diff --git a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs index 9b3c4dc92..4089cc8c7 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs @@ -732,7 +732,7 @@ LIMIT 1 private SymbolCandidateSelector BuildSymbolCandidateSelector(DefinitionResult definition) { var container = definition.ContainerQualifiedName ?? definition.ContainerName; - var qualifiedName = definition.SubKind == SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind + var qualifiedName = SyntheticSymbolIdentity.IsSyntheticSubKind(definition.SubKind) ? SyntheticSymbolIdentity.BuildFileQualifiedName(definition.Path, definition.Name) : string.IsNullOrWhiteSpace(container) ? definition.Name diff --git a/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.Candidates.cs b/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.Candidates.cs index f12449521..eb6cae7dc 100644 --- a/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.Candidates.cs +++ b/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.Candidates.cs @@ -1,4 +1,5 @@ using System.Text; +using CodeIndex.Models; using Microsoft.Data.Sqlite; namespace CodeIndex.Database; @@ -210,6 +211,12 @@ private void AppendUnusedCandidateSourceSql( FROM symbols s JOIN files f ON s.file_id = f.id WHERE s.kind NOT IN ('import', 'namespace')"); + if (_symbolColumns.Contains("sub_kind")) + { + sql.Append("\n AND COALESCE(s.sub_kind, '') <> '"); + sql.Append(SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind); + sql.Append('\''); + } sql.Append(BuildUnusedReferenceAbsenceSql(resolveSqlReferences)); if (useCSharpPartialUseExclusion || (resolveSqlReferences && hasUsableUnusedChunks)) { diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs index ca36c5531..a39e91405 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpTopLevel.cs @@ -8,9 +8,9 @@ private static void AddCSharpTopLevelScopeSymbol( long fileId, string[] lines, string[] structuralLines, - List symbols) + SymbolExtractionList symbols) { - if (structuralLines.Length == 0) + if (structuralLines.Length == 0 || symbols.IsAtCapacity) return; // This runs after AssignContainers. Declaration ranges can therefore be excluded @@ -142,8 +142,7 @@ private static bool IsCSharpNonExecutableFileScopeLine( } if (!line.StartsWith("using ", StringComparison.Ordinal)) return false; - if (line.StartsWith("using var ", StringComparison.Ordinal) - || line.StartsWith("using (", StringComparison.Ordinal)) + if (IsCSharpUsingDeclaration(line)) { return false; } @@ -152,6 +151,30 @@ private static bool IsCSharpNonExecutableFileScopeLine( return true; } + private static bool IsCSharpUsingDeclaration(string line) + { + if (line.StartsWith("using var ", StringComparison.Ordinal) + || line.StartsWith("using (", StringComparison.Ordinal)) + { + return true; + } + + var equalsIndex = line.IndexOf('='); + if (equalsIndex < 0) + return false; + + var declarationPrefix = line["using ".Length..equalsIndex].Trim(); + if (declarationPrefix.StartsWith("unsafe ", StringComparison.Ordinal)) + declarationPrefix = declarationPrefix["unsafe ".Length..].TrimStart(); + + // An alias directive has one identifier before '=', while an explicit using + // declaration has a type and variable name. Structural masking has already + // replaced comments with whitespace before this classification. + // alias directive の '=' より前は識別子 1 個だが、明示型 using declaration + // には型と変数名がある。comment はこの判定前に空白へ mask 済み。 + return declarationPrefix.AsSpan().IndexOfAny(' ', '\t') >= 0; + } + private static bool IsCSharpFileScopeAttributeStart(string line) => line.StartsWith('['); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index 0d03a6e2f..c7870a6a7 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -295,14 +295,66 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) var rawKinds = args?["rawKinds"]?.GetValue() ?? false; var includeQualifiedCommonCalls = args?["includeQualifiedCommonCalls"]?.GetValue() ?? false; var includeMemberReads = args?["includeMemberReads"]?.GetValue() ?? false; + var selectedSymbol = SymbolSelector.TryParse(query, out var parsedSelector) + ? parsedSelector + : (SymbolSelector?)null; return WithDbReader(id, args, reader => { + DefinitionResult? selectedDefinition = null; + if (selectedSymbol is { } selector) + { + if (!reader.IsCurrentSymbolSelector(selector)) + { + return CreateToolErrorResponse( + id, + $"Symbol selector is stale or belongs to another index generation: {selector}.", + McpErrorEnvelope.CategoryIndexStale, + "Rerun outline or inspect against this database and use the current emitted selector.", + retrySafe: true); + } + + selectedDefinition = reader.GetDefinitionBySelector(selector, lang); + if (selectedDefinition == null) + { + return CreateToolErrorResponse( + id, + $"Symbol selector was not found in the active index: {selector}.", + McpErrorEnvelope.CategoryInvalidArgument, + "Rerun outline or inspect and use a selector emitted by the active database.", + retrySafe: false); + } + } + + var effectiveLang = selectedDefinition?.Lang ?? lang; + var effectiveQuery = selectedDefinition?.Name ?? query; if (countOnly) { - var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls, includeMemberReads).Count; + var countOnlyTotal = selectedDefinition != null + ? reader.CountCalleesForCandidate( + selectedDefinition, + pathPatterns, + excludePaths, + excludeTests, + kind, + rawKinds, + includeQualifiedCommonCalls, + includeMemberReads).Count + : reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls, includeMemberReads).Count; var histogramResults = countOnlyTotal > 0 - ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads) + ? selectedDefinition != null + ? reader.GetCalleesForCandidate( + selectedDefinition, + Math.Min(countOnlyTotal, MaxLimit), + pathPatterns, + excludePaths, + excludeTests, + referenceKind: kind, + rawKinds: rawKinds, + rankMode: rankMode, + includeQualifiedCommonCalls: includeQualifiedCommonCalls, + includeMemberReads: includeMemberReads) + : reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; @@ -310,7 +362,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) countOnlyPayload["rawKinds"] = rawKinds; countOnlyPayload["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls; countOnlyPayload["includeMemberReads"] = includeMemberReads; - countOnlyPayload["lang"] = lang; + countOnlyPayload["lang"] = effectiveLang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; countOnlyPayload["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode); @@ -318,7 +370,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) AddReferenceGraphCompletenessSignal( countOnlyPayload, reader, - lang, + effectiveLang, pathPatterns, excludePaths, excludeTests); @@ -326,20 +378,50 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); } - var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads); + var results = selectedDefinition != null + ? reader.GetCalleesForCandidate( + selectedDefinition, + FetchLimitForEnvelope(limit), + pathPatterns, + excludePaths, + excludeTests, + offset, + kind, + rawKinds, + rankMode, + includeQualifiedCommonCalls, + includeMemberReads) + : reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls, includeMemberReads).Count + ? selectedDefinition != null + ? reader.CountCalleesForCandidate( + selectedDefinition, + pathPatterns, + excludePaths, + excludeTests, + kind, + rawKinds, + includeQualifiedCommonCalls, + includeMemberReads).Count + : reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls, includeMemberReads).Count : results.Count; - var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); + var graphSupport = ResolveGraphSupport( + reader, + selectedDefinition != null || exact, + effectiveQuery, + effectiveLang, + pathPatterns, + excludePaths, + excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( - reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), + reader.GetSqlGraphContractSignal(effectiveLang, pathPatterns, excludePaths, excludeTests), results.Select(result => result.Lang), - lang, + effectiveLang, graphSupport.GraphLanguage); - var exactSignal = reader.GetCalleesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); + var exactSignal = reader.GetCalleesExactQuerySignal(effectiveLang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( - exact && reader._hasReferencesTable, + selectedDefinition == null && exact && reader._hasReferencesTable, () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads) > 0, () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads), () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls, includeMemberReads: includeMemberReads), @@ -351,7 +433,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["rawKinds"] = rawKinds, ["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls, ["includeMemberReads"] = includeMemberReads, - ["lang"] = lang, + ["lang"] = effectiveLang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), @@ -371,14 +453,17 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) AddReferenceGraphCompletenessSignal( payload, reader, - lang, + effectiveLang, pathPatterns, excludePaths, excludeTests); if (results.Count == 0) { - AddExactZeroHint(payload, exactZeroHint); - AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); + if (selectedDefinition == null) + { + AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); + } AddFreshnessHint(payload, reader); } else diff --git a/tests/CodeIndex.Tests/DbReaderUnusedCandidateQueryTests.cs b/tests/CodeIndex.Tests/DbReaderUnusedCandidateQueryTests.cs index b6e40220e..e5a171aca 100644 --- a/tests/CodeIndex.Tests/DbReaderUnusedCandidateQueryTests.cs +++ b/tests/CodeIndex.Tests/DbReaderUnusedCandidateQueryTests.cs @@ -5,6 +5,46 @@ namespace CodeIndex.Tests; public partial class DbReaderTests { + [Fact] + public void UnusedCandidateQueries_ExcludeCSharpTopLevelSyntheticEntryPoint_Issue5164() + { + var csharpFileId = CreateMixedCandidateFile("src/issue5164-unused.cs", "csharp", 1); + var sqlFileId = CreateMixedCandidateFile("src/issue5164-unused.sql", "sql", 1); + _writer.InsertSymbols( + [ + new SymbolRecord + { + FileId = csharpFileId, + Kind = "function", + SubKind = SyntheticSymbolIdentity.CSharpTopLevelScopeSubKind, + Name = SyntheticSymbolIdentity.CSharpTopLevelScopeName, + Line = 1, + StartLine = 1, + EndLine = 1, + }, + CreateMixedCandidate(sqlFileId, "dbo.ActuallyUnused", 1, 1), + ]); + + var csharpOnly = _reader.GetUnusedSymbols( + 10, "function", "csharp", ["src/issue5164-unused.*"], null, excludeTests: false); + var csharpOnlyCount = _reader.CountUnusedSymbols( + "function", "csharp", ["src/issue5164-unused.*"], null, excludeTests: false); + var mixed = _reader.GetUnusedSymbols( + 10, "function", null, ["src/issue5164-unused.*"], null, excludeTests: false); + var mixedCount = _reader.CountUnusedSymbols( + "function", null, ["src/issue5164-unused.*"], null, excludeTests: false); + var detailedCount = _reader.CountUnusedSymbolsDetailed( + "function", null, ["src/issue5164-unused.*"], null, excludeTests: false); + + Assert.Empty(csharpOnly); + Assert.Equal(new QueryCountResult(0, 0), csharpOnlyCount); + Assert.Equal("dbo.ActuallyUnused", Assert.Single(mixed).Name); + Assert.Equal(new QueryCountResult(1, 1, IncludesSql: true), mixedCount); + Assert.Equal(1, detailedCount.Count); + Assert.Equal(1, detailedCount.FileCount); + Assert.True(detailedCount.IncludesSql); + } + [Fact] public void UnusedCandidateQueries_MixedSqlScopePreservesProjectionAndCounts() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index e73310f8e..cbf490dfe 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -6827,10 +6827,14 @@ public void ToolsCall_Outline_ReturnsSymbols() [Fact] public void ToolsCall_Outline_ReturnsActionableCSharpTopLevelScope_Issue5164() { + InsertIndexedFile( + "src/target.cs", + "csharp", + "public static class Target { public static void Run() { } }\n"); InsertIndexedFile( "src/top-level.cs", "csharp", - "using System;\nConsole.WriteLine(\"mcp\");\n"); + "using System;\nTarget.Run();\n"); var request = JsonNode.Parse( """{"jsonrpc":"2.0","id":5164,"method":"tools/call","params":{"name":"outline","arguments":{"path":"src/top-level.cs"}}}""")!; @@ -6849,7 +6853,49 @@ public void ToolsCall_Outline_ReturnsActionableCSharpTopLevelScope_Issue5164() Assert.Equal(2, symbol["startLine"]!.GetValue()); Assert.Equal(2, symbol["endLine"]!.GetValue()); Assert.Equal("src/top-level.cs::", symbol["qualified_name"]!.GetValue()); - Assert.StartsWith("id:", symbol["selector"]!.GetValue(), StringComparison.Ordinal); + var selector = symbol["selector"]!.GetValue(); + Assert.StartsWith("id:", selector, StringComparison.Ordinal); + + var calleesRequest = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 5164, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "callees", + ["arguments"] = new JsonObject + { + ["query"] = selector, + ["path"] = "src/top-level.cs", + }, + }, + }; + var callees = _server.HandleMessage(calleesRequest)!["result"]!["structuredContent"]!; + var callee = Assert.Single(callees["results"]!.AsArray())!; + Assert.Equal(1, callees["count"]!.GetValue()); + Assert.Equal("Run", callee["calleeName"]!.GetValue()); + Assert.Equal("src/top-level.cs", callee["path"]!.GetValue()); + + var countRequest = calleesRequest.DeepClone(); + countRequest["params"]!["arguments"]!["countOnly"] = true; + var count = _server.HandleMessage(countRequest)!["result"]!["structuredContent"]!; + Assert.Equal(1, count["count"]!.GetValue()); + + InsertIndexedFile( + "src/generation-change.cs", + "csharp", + "internal sealed class GenerationChange { }\n"); + var staleResponse = _server.HandleMessage(calleesRequest)!; + var staleResult = staleResponse["result"]!; + Assert.True(staleResult["isError"]!.GetValue()); + Assert.Contains( + "stale", + staleResult["content"]![0]!["text"]!.GetValue(), + StringComparison.OrdinalIgnoreCase); + Assert.Equal( + McpErrorEnvelope.CategoryIndexStale, + staleResult["structuredContent"]!["category"]!.GetValue()); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs index 98dec4d60..958dbaaf7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5164Tests.cs @@ -8,6 +8,47 @@ namespace CodeIndex.Tests; public sealed class QueryCommandRunnerIssue5164Tests { + [Fact] + public void ScriptScope_OutlineAndInspectExposeSameFileQualifiedIdentity_Issue5164Review() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_script_scope_identity_5164"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "scripts/run.sh", + "shell", + "echo ready\n"); + MarkCurrentContracts(dbPath); + + using var outline = RunOutline(dbPath, "scripts/run.sh"); + var scriptScope = Assert.Single(outline.RootElement.GetProperty("symbols").EnumerateArray(), symbol => + symbol.GetProperty("name").GetString() == "