From 26db3541275887b1fe39ea951406ae9c71a2628a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 06:01:47 +0900 Subject: [PATCH 1/3] Fix qualified C# common calls (#4867) --- DEVELOPER_GUIDE.md | 17 ++ TESTING_GUIDE.md | 2 + USER_GUIDE.md | 4 + changelog.d/unreleased/4867.fixed.md | 22 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 6 +- ...eryCommandRunner.ArgumentParser.Results.cs | 3 + .../Cli/QueryCommandRunner.ArgumentParser.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 48 ++--- .../Cli/QueryCommandRunner.ResultEnvelopes.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.cs | 1 + .../Database/DbReader.GraphQueries.cs | 98 ++++++---- src/CodeIndex/Database/DbReader.References.cs | 49 +++-- .../Database/HotspotReferenceAggregateSql.cs | 14 ++ .../Languages/CSharpReferenceExtractor.cs | 48 +---- ...ferenceExtractor.CoreCallClassification.cs | 8 - src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 4 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 3 + .../Mcp/McpToolHandlers.ArgumentValidation.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 51 +++--- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 13 ++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- tests/CodeIndex.Tests/DbReaderTests.cs | 171 +++++++++++++++++- .../McpServerToolsCallTests.cs | 3 +- .../McpServerToolsListTests.cs | 16 ++ .../ReferenceExtractorCSharpTests.cs | 45 ++++- 26 files changed, 466 insertions(+), 169 deletions(-) create mode 100644 changelog.d/unreleased/4867.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 800a5e660..fe30c00c9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1119,6 +1119,15 @@ unqualified name receive a global candidate only when that name is unique in the symbol set. Otherwise they remain `ambiguous` or `unresolved`, and dependency queries do not fall back to a same-name edge. +C# common member names are never discarded during extraction. The writer persists their +receiver/type evidence in `target_qualifier`, and reference finalization records +`resolution_state`. Default bare-name `references` / `callers` / `callees` queries and hotspot +aggregation suppress only rows that are both qualified and unresolved; `resolved` and +`resolved_group` rows remain authoritative by default. The CLI +`--include-qualified-common-calls` flag and MCP `includeQualifiedCommonCalls` argument bypass +that query-time noise filter. Keep dependency edges identity-scoped: the completeness option +must expose unresolved evidence without converting it into a same-name file dependency. + C# `type_reference` candidates are filtered before qualifier and namespace ranking. A candidate must be a type-like symbol (`class`, `struct`, `record`, `interface`, `enum`, or `delegate`), and when the reference's generic arity can be recovered from its normalized line context, that arity @@ -4394,6 +4403,14 @@ resolution を再構築し、同じ transaction で marker を設定します。 対象となる symbol 集合で名前が一意の場合だけ global candidate を持ちます。それ以外は `ambiguous` または `unresolved` のままとし、dependency query は同名 edge へ fallback しません。 +C# の一般的な member 名は extraction 時に破棄しません。writer は receiver / 型の evidence を +`target_qualifier` に永続化し、reference finalization は `resolution_state` を記録します。無修飾名の +`references` / `callers` / `callees` query と hotspot 集計の既定動作では、修飾され、かつ未解決の +row だけを除外し、`resolved` / `resolved_group` row は authoritative な既定結果として維持します。 +CLI の `--include-qualified-common-calls` と MCP の `includeQualifiedCommonCalls` は、この query-time +noise filter を無効化します。dependency edge は identity scope のままにし、completeness option で +未解決 evidence を公開しても、同名の file dependency へ変換してはいけません。 + C# の `type_reference` candidate は qualifier / namespace の順位付け前に絞り込みます。candidate は 型相当の symbol(`class`、`struct`、`record`、`interface`、`enum`、`delegate`)でなければならず、 正規化済み行 context から reference の generic arity を復元できる場合は declaration の arity と diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index f03d746f9..03a497f6d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -95,6 +95,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side, named-`out`, explicitly typed lambda/anonymous-method, and typed LINQ range-variable type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). + C# qualified common-call coverage keeps static BCL, instance, LINQ extension, alias-qualified, current-instance, and unresolved-receiver cases together. Assert that extraction retains every row, default bare-name references/callers/callees and hotspot counts retain resolved evidence while excluding unresolved noise, the completeness option restores that noise deterministically, and dependency queries remain identity-scoped (#4867). Crystal, Groovy, Tcl, Prolog, and `ambiguous_pl` graph fixtures keep imports, parenthesized calls, conservative same-file command/predicate calls, caller containers, and keyword false-positive controls independently diagnosable; capability tests must keep their advertised symbol/reference/graph flags aligned with those extractor fixtures (#4746). Database status tests must also pin stale and current extractor-version stamps for those languages so pre-graph rows cannot report authoritative graph readiness. HDL graph coverage keeps Verilog, SystemVerilog, and VHDL in one coordinated extractor fixture for syntax-visible hierarchy/package/architecture edges, known same-file signal/type references, scope attribution, declaration/literal false-positive suppression, and comment masking. The paired `QueryCommandRunnerReferencesTests` fixture must persist and query one representative edge per HDL language so extractor-only assertions cannot hide database or CLI graph drift. `IndexCommandRunnerFullScanTests` must also clear the HDL graph contract and persisted edge, then prove that a normal full scan refreshes an unchanged HDL file before restoring readiness. @@ -1008,6 +1009,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側、named `out` declaration、明示型 lambda / anonymous method、および型付き LINQ range variable の type reference、property subpattern の型、ならびに alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 + C# の修飾付き一般名 call の coverage は、static BCL、instance、LINQ extension、alias 修飾、current instance、未解決 receiver の各 case を同じ fixture に維持します。extraction が全 row を保持すること、無修飾名による references / callers / callees と hotspot count の既定動作が解決済み evidence を維持しつつ未解決 noise を除外すること、completeness option がその noise を決定的に復元すること、dependency query が identity scope のままであることを検証してください(#4867)。 Crystal、Groovy、Tcl、Prolog、`ambiguous_pl` の graph fixture では、import、括弧付き call、同一ファイルに限定した保守的な command / predicate call、caller container、keyword の false-positive control を個別に診断可能な状態で維持し、capability test の symbol / reference / graph 広告を extractor fixture と一致させてください(#4746)。 さらに database status test でこれらの言語の古い extractor-version stamp と現行 stamp を固定し、graph 対応前の row が authoritative な graph readiness を報告できないことを検証してください。 HDL graph coverage では Verilog、SystemVerilog、VHDL を1つの連携した extractor fixture にまとめ、構文上確認できる hierarchy/package/architecture edge、同一ファイル内の既知 signal/type reference、scope attribution、declaration/literal の false-positive 抑止、comment masking を検証します。対になる `QueryCommandRunnerReferencesTests` fixture では各 HDL 言語の代表 edge を1件ずつ永続化して query し、extractor-only assertion が database または CLI graph のずれを隠せないようにします。さらに `IndexCommandRunnerFullScanTests` では HDL graph contract と永続化 edge を消去し、通常の full scan が未変更 HDL file を更新してから readiness を復元することを検証します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 61f3590fd..37f580a44 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1735,6 +1735,8 @@ When you pass `--lang` for an unsupported language, human-readable graph command By default, `callers` and `callees` return only executable call, construction, and subscription edges. Their public `reference_kind`, `reference_kinds`, and `reference_kind_counts` fields use one canonical vocabulary: `call`, `instantiate`, and `subscribe`. Type and metadata edges such as `generic_type_argument`, `capture`, `friend`, and `project_reference` remain available through `references` or an explicit kind filter; use `--raw-kinds` when you need extractor labels such as `unsubscribe` or `razor_event_binding`. +C# indexing retains receiver- and type-qualified calls even when their member name is common, including `int.Parse`, LINQ `Where` / `Select` / `ToList`, and instance `Read` / `Write` calls. Bare-name `references`, `callers`, and `callees` queries keep resolved qualified calls in the default result while suppressing only unresolved qualified common-name calls as noise. Use `--include-qualified-common-calls` (or MCP `includeQualifiedCommonCalls: true`) when you need those unresolved rows too; an explicit qualified query such as `references int.Parse --exact` is already treated as an intentional completeness request. + `callers` and `callees` rank results by weighted structural importance by default: `instantiate` edges count as `3.0`, direct `call` edges as `1.0`, and event `subscribe` edges as `0.1`. This keeps factory or composition-root constructor use from being buried under noisy event subscriptions. Use `--rank-by count` to restore raw `reference_count` ordering, or `--rank-by kind` to group `instantiate`, `call`, then `subscribe` before count. JSON rows keep the raw `reference_count` and add `reference_kind_counts` plus `reference_weight_score` so consumers can re-rank without losing the source counts; MCP structured rows expose the same data as `referenceKindCounts` and `referenceWeightScore`. Grouped `callees` rows preserve the earliest precise call site separately from the aggregate `reference_count`. CLI JSON exposes its 1-based `first_line`, nullable `first_column`, and persisted physical-token `first_length`; MCP uses the corresponding `firstLine`, `firstColumn`, and `firstLength` fields. This keeps resolved constructor-chain calls on their actual `base`, `super`, or `this` token. Compact and quickfix output use the same 1-based column, SARIF carries the same token-sized region, and LSP converts it to a zero-based, non-empty token range. If every contributing legacy reference lacks a column, the column remains `null`, quickfix reports column `0`, SARIF omits `endColumn`, and LSP returns a zero-width range at the start of the retained source line. If a migrated row retains its column but has no persisted span length, SARIF still omits `endColumn` and LSP returns a zero-width range at that column instead of inventing a token width. @@ -4954,6 +4956,8 @@ cdidx callees AddToGitExclude --exclude-tests 既定の `callers` / `callees` は、実行可能な call、construction、subscription edge だけを返します。公開される `reference_kind`、`reference_kinds`、`reference_kind_counts` は、`call`、`instantiate`、`subscribe` という 1 つの canonical 語彙を共有します。`generic_type_argument`、`capture`、`friend`、`project_reference` などの型 / metadata edge は `references` または明示 kind filter で引き続き利用できます。`unsubscribe` や `razor_event_binding` のような extractor label が必要な場合は `--raw-kinds` を使ってください。 +C# の indexing は member 名が一般的な場合でも、`int.Parse`、LINQ の `Where` / `Select` / `ToList`、instance の `Read` / `Write` など、receiver または型で修飾された call を保持します。無修飾名による `references`、`callers`、`callees` query の既定結果では、解決済みの修飾 call を維持し、未解決の修飾付き一般名 call だけを noise として除外します。未解決 row も必要な場合は `--include-qualified-common-calls`(MCP では `includeQualifiedCommonCalls: true`)を使ってください。`references int.Parse --exact` のような明示的な修飾 query は、それ自体を completeness の指定として扱います。 + `callers` と `callees` は既定で構造的重要度の weighted 順に並びます。`instantiate` は `3.0`、直接 `call` は `1.0`、event `subscribe` は `0.1` として数えるため、factory や composition root の constructor 利用が大量の event subscription に埋もれにくくなります。従来どおり生の `reference_count` で並べたい場合は `--rank-by count`、reference kind を優先して `instantiate`、`call`、`subscribe` の順でまとめたい場合は `--rank-by kind` を使ってください。JSON の各行は生の `reference_count` を維持し、`reference_kind_counts` と `reference_weight_score` も追加で返します。MCP structured row では同じ情報を `referenceKindCounts` と `referenceWeightScore` として返すため、consumer 側で再ランキングできます。 集約された `callees` 行は、集約値の `reference_count` とは別に、最初の精密な call site を保持します。CLI JSON は 1-based の `first_line`、nullable な `first_column`、永続化された物理 token 幅の `first_length` を返し、MCP は対応する `firstLine`、`firstColumn`、`firstLength` を使います。そのため、解決後の constructor-chain call も実際の `base`、`super`、`this` token を指します。compact / quickfix も同じ 1-based 列を使い、SARIF は同じ token 幅の region、LSP は 0-based の非空 token range に変換します。寄与する legacy reference の全てで列が欠けている場合は列を `null` のまま保ち、quickfix は列 `0`、SARIF は `endColumn` を省略し、LSP は保持した source line の先頭に zero-width range を返します。移行済み row に列はあるものの永続化 span 長がない場合も、SARIF は `endColumn` を省略し、LSP はその列に zero-width range を返すため、token 幅を捏造しません。 diff --git a/changelog.d/unreleased/4867.fixed.md b/changelog.d/unreleased/4867.fixed.md new file mode 100644 index 000000000..b4faca61c --- /dev/null +++ b/changelog.d/unreleased/4867.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4867 +affected: + - src/CodeIndex/Indexer/References + - src/CodeIndex/Database + - src/CodeIndex/Cli + - src/CodeIndex/Mcp + - tests/CodeIndex.Tests + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# qualified common-name calls are retained and filtered by resolution evidence (#4867)** — Indexing now preserves static BCL, instance, extension, aliased, and unresolved qualified calls such as `int.Parse`, `Where`, `Select`, `ToList`, `Read`, and `Write`. Bare-name `references`, `callers`, `callees`, and hotspot defaults suppress only unresolved qualified noise while retaining resolved calls; `--include-qualified-common-calls` and MCP `includeQualifiedCommonCalls` expose the complete deterministic evidence without creating unresolved dependency edges. + +## 日本語 + +- **C# の修飾付き一般名 call を保持し、解決 evidence に基づいて絞り込むようにしました (#4867)** — indexing は `int.Parse`、`Where`、`Select`、`ToList`、`Read`、`Write` など、static BCL、instance、extension、alias 修飾、未解決の修飾 call を保持します。無修飾名による `references`、`callers`、`callees` と hotspot の既定動作は解決済み call を維持し、未解決の修飾 noise だけを除外します。`--include-qualified-common-calls` と MCP の `includeQualifiedCommonCalls` で、未解決 dependency edge を作らずに完全で決定的な evidence を取得できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index fc7ad2d8e..71b1ea885 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -471,6 +471,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by weighted structural score, raw count, or kind bucket", PrimaryCommands = Set(RankByCommands) }, new() { Name = "--sort", ValuePlaceholder = "", Description = "Symbols/outline: order audit output by a ranking signal; outline also accepts source, kind, references, size, complexity, path, and name", PrimaryCommands = Set(SymbolSortCommands) }, new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", PrimaryCommands = Set(RawKindsCommands) }, + new() { Name = "--include-qualified-common-calls", Description = "Include unresolved receiver/type-qualified C# calls with common member names", PrimaryCommands = Set("references", "callers", "callees") }, new() { Name = "--count", Description = "Count only; result limits are ignored by count modes, but scan caps can still mark approximate counts as degraded", PrimaryCommands = Set(CountCommands) }, new() { Name = "--group-partials", Description = "Definition/Symbols/Inspect: collapse C# partial-type declarations into logical families", PrimaryCommands = Set("definition", "symbols", "inspect") }, new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", PrimaryCommands = Set(StrictNotFoundCommands), Safety = CliOptionSafety.StrictFailure }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a561dcfcd..d5a233d47 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -89,9 +89,9 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("audit", "cdidx audit [search filters] [--json[=ndjson]] [--format ] [--summary-only] [--limit ] [--total-limit ] [--results-only] [--search-fields ] [--first-per-file] [--sample ] [--max-json-bytes ] [--snippet-lines ]"), ("definition", "cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact-name] [--all]"), - ("references", "cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("callers", "cdidx callers |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("callees", "cdidx callees |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("references", "cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("callers", "cdidx callers |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("callees", "cdidx callees |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json[=ndjson|array]] [--compact] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), ("files", "cdidx files [query||--query |-- ] [--db ] [--json[=ndjson|array]] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs index 04535c3df..850183be4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs @@ -239,6 +239,9 @@ private bool TryParseResultOption(string normalizedArg, string currentArg, strin case "--raw-kinds": rawKinds = true; break; + case "--include-qualified-common-calls": + includeQualifiedCommonCalls = true; + break; case "--verbose": verbose = true; break; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index e6efd4fdd..c9618da46 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -115,6 +115,7 @@ private sealed partial class QueryArgumentParser private int? maxJsonBytes; private bool rawBytes; private bool rawKinds; + private bool includeQualifiedCommonCalls; private bool verbose; private bool profile; private int? slowQueryMs; @@ -474,6 +475,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r MaxJsonBytes = maxJsonBytes, RawBytes = rawBytes, RawKinds = rawKinds, + IncludeQualifiedCommonCalls = includeQualifiedCommonCalls, Verbose = verbose, Profile = profile, SlowQueryMs = slowQueryMs, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 26d375c59..b4f60d0f8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -39,16 +39,16 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti : null; if (options.CountOnly) { - var counts = reader.CountSearchReferencesTotal(options.Query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact); + var counts = reader.CountSearchReferencesTotal(options.Query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.IncludeQualifiedCommonCalls); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); var exactSignalForCount = reader.GetReferencesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: effectiveSqlGraphSignal.Relevant); var exactZeroHintForCount = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountSearchReferences(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false) > 0, - () => reader.CountSearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false), - () => reader.SearchReferences(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false), + () => reader.CountSearchReferences(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountSearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.SearchReferences(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.SymbolName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignalForCount, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, effectiveSqlGraphSignal, reader, options); @@ -63,7 +63,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti return CommandExitCodes.Success; } - var results = reader.SearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("references")); + var results = reader.SearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("references"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); @@ -71,9 +71,9 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti var exactSignal = reader.GetReferencesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountSearchReferences(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false) > 0, - () => reader.CountSearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false), - () => reader.SearchReferences(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false), + () => reader.CountSearchReferences(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountSearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.SearchReferences(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.SymbolName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignal, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); @@ -178,16 +178,16 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions : null; if (options.CountOnly) { - var counts = reader.CountCallersTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); + var counts = reader.CountCallersTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.IncludeQualifiedCommonCalls); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); var exactSignalForCount = reader.GetCallersExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: effectiveSqlGraphSignal.Relevant); var exactZeroHintForCount = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.CalleeName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignalForCount, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, effectiveSqlGraphSignal, reader, options); @@ -202,7 +202,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } - var results = reader.GetCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers")); + var results = reader.GetCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); @@ -210,9 +210,9 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var exactSignal = reader.GetCallersExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.CalleeName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignal, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); @@ -317,16 +317,16 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions : null; if (options.CountOnly) { - var counts = reader.CountCalleesTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); + var counts = reader.CountCalleesTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.IncludeQualifiedCommonCalls); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); var exactSignalForCount = reader.GetCalleesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: effectiveSqlGraphSignal.Relevant); var exactZeroHintForCount = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.CallerName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignalForCount, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, effectiveSqlGraphSignal, reader, options); @@ -341,7 +341,7 @@ 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")); + 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); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); @@ -349,9 +349,9 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var exactSignal = reader.GetCalleesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls) > 0, + () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), + () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode, includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls), r => r.CallerName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignal, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index 922f15325..c1852662b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -284,6 +284,8 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso } if (options.RawKinds) query["raw_kinds"] = true; + if (options.IncludeQualifiedCommonCalls) + query["include_qualified_common_calls"] = true; if (options.DependencyCycles) { query["cycles"] = true; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5b120e5ef..fbabea01a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -165,6 +165,7 @@ public sealed class QueryCommandOptions public int? MaxJsonBytes { get; init; } public bool RawBytes { get; init; } public bool RawKinds { get; init; } + public bool IncludeQualifiedCommonCalls { get; init; } public bool Verbose { get; init; } public bool Profile { get; init; } public int? SlowQueryMs { get; init; } diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index cd72e3183..c31f48492 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -7,30 +7,34 @@ namespace CodeIndex.Database; public partial class DbReader { - private static readonly HashSet CSharpReceiverQualifiedBareMemberFilterNames = new(StringComparer.Ordinal) - { - "GetString", - "ToList", - "NewGuid", - }; + private static readonly string CSharpCommonQualifiedMemberCallNamesSql = string.Join( + ", ", + CSharpReferenceExtractor.CommonQualifiedMemberCallNames + .OrderBy(static name => name, StringComparer.Ordinal) + .Select(static name => $"'{name}'")); - private static string BuildCSharpBareMemberGraphReferenceFilter( + private static string BuildCSharpBareMemberReferenceFilter( string query, string? lang, - bool exact, - string contextSql, string fileAlias, - string referenceAlias) + string referenceAlias, + bool includeQualifiedCommonCalls) { - if (!ShouldFilterCSharpReceiverQualifiedBareMemberQuery(query, lang, exact)) + if (includeQualifiedCommonCalls + || !ShouldFilterCSharpQualifiedCommonBareMemberQuery(query, lang)) return string.Empty; - return $" AND NOT ({fileAlias}.lang = 'csharp' AND {referenceAlias}.reference_kind = 'call' AND (instr({contextSql}, '.' || {referenceAlias}.symbol_name || '(') > 0 OR instr({contextSql}, '.' || {referenceAlias}.symbol_name || '<') > 0 OR ({referenceAlias}.column_number > 1 AND substr({contextSql}, {referenceAlias}.column_number - 1, 1) = '.' AND substr(ltrim(substr({contextSql}, {referenceAlias}.column_number + length({referenceAlias}.symbol_name))), 1, 1) IN ('(', '<'))))"; + return BuildCSharpQualifiedCommonCallNoiseFilter(fileAlias, referenceAlias); } - private static bool ShouldFilterCSharpReceiverQualifiedBareMemberQuery(string query, string? lang, bool exact) + private static string BuildCSharpQualifiedCommonCallNoiseFilter( + string fileAlias, + string referenceAlias) + => $" AND NOT ({fileAlias}.lang = 'csharp' AND {referenceAlias}.reference_kind = 'call' AND {referenceAlias}.symbol_name IN ({CSharpCommonQualifiedMemberCallNamesSql}) AND {referenceAlias}.target_qualifier IS NOT NULL AND COALESCE({referenceAlias}.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group'))"; + + private static bool ShouldFilterCSharpQualifiedCommonBareMemberQuery(string query, string? lang) { - if (!exact || lang is not null and not "csharp" || SqlNameResolver.HasQualifier(query)) + if (lang is not null and not "csharp" || SqlNameResolver.HasQualifier(query)) return false; if (string.IsNullOrWhiteSpace(query)) @@ -41,18 +45,18 @@ private static bool ShouldFilterCSharpReceiverQualifiedBareMemberQuery(string qu return false; return value.All(c => char.IsLetterOrDigit(c) || c == '_') - && CSharpReceiverQualifiedBareMemberFilterNames.Contains(value); + && CSharpReferenceExtractor.CommonQualifiedMemberCallNames.Contains(value); } /// /// Find callers for a referenced symbol. /// 指定シンボルを呼び出している呼び出し元を探す。 /// - public List GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false, int offset = 0) - => GetCallersCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, excludeSelfReferences, offset, targetSymbolId: null); + public List GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false, int offset = 0, bool includeQualifiedCommonCalls = false) + => GetCallersCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, excludeSelfReferences, offset, includeQualifiedCommonCalls, targetSymbolId: null); private List GetCallersForCandidate(DefinitionResult definition, int limit, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, int offset = 0) - => GetCallersCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, excludeSelfReferences: false, offset, definition.SymbolId); + => GetCallersCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, excludeSelfReferences: false, offset, includeQualifiedCommonCalls: false, targetSymbolId: definition.SymbolId); private int CountCallersForCandidate(DefinitionResult definition, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) { @@ -68,10 +72,11 @@ private int CountCallersForCandidate(DefinitionResult definition, IReadOnlyList< excludeTests, exact: true, rawKinds: false, + includeQualifiedCommonCalls: false, symbolId).Count; } - private List GetCallersCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, bool excludeSelfReferences, int offset, long? targetSymbolId) + private List GetCallersCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, bool excludeSelfReferences, int offset, bool includeQualifiedCommonCalls, long? targetSymbolId) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return new List(); @@ -188,7 +193,12 @@ FROM symbol_reference_candidates AS identity_candidate ? " AND (f.lang = @lang OR f.lang = 'ambiguous_m')" : " AND f.lang = @lang"; } - sql += BuildCSharpBareMemberGraphReferenceFilter(query, lang, exact, contextSql, "f", "r"); + sql += BuildCSharpBareMemberReferenceFilter( + query, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += @" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, " + groupedReferenceKindGroupSql + @", r.reference_kind @@ -274,7 +284,7 @@ FROM logical_references r return results; } - public int CountCallers(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) + public int CountCallers(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, bool includeQualifiedCommonCalls = false) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return 0; @@ -350,7 +360,12 @@ FROM symbol_references r : " AND (r.symbol_name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@aliasQuery) COLLATE NOCASE))"; if (lang != null) groupedSql += " AND f.lang = @lang"; - groupedSql += BuildCSharpBareMemberGraphReferenceFilter(query, lang, exact, contextSql, "f", "r"); + groupedSql += BuildCSharpBareMemberReferenceFilter( + query, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref groupedSql, pathPatterns, excludePathPatterns, excludeTests); groupedSql += $" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {(rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"))}"; groupedSql += " ) grouped_call_sites GROUP BY path, lang, container_kind, container_name, symbol_name LIMIT @limit"; @@ -386,7 +401,7 @@ FROM symbol_references r return raw is long l ? (int)l : Convert.ToInt32(raw); } - public QueryCountResult CountCallersTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false) + public QueryCountResult CountCallersTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, bool includeQualifiedCommonCalls = false) => CountCallersTotalCore( query, lang, @@ -396,6 +411,7 @@ public QueryCountResult CountCallersTotal(string query, string? lang = null, str excludeTests, exact, rawKinds, + includeQualifiedCommonCalls, targetSymbolId: null); private QueryCountResult CountCallersTotalCore( @@ -407,6 +423,7 @@ private QueryCountResult CountCallersTotalCore( bool excludeTests, bool exact, bool rawKinds, + bool includeQualifiedCommonCalls, long? targetSymbolId) { if (!_hasReferencesTable) @@ -498,7 +515,12 @@ FROM symbol_reference_candidates AS identity_candidate ? " AND (f.lang = @lang OR f.lang = 'ambiguous_m')" : " AND f.lang = @lang"; } - groupedSql += BuildCSharpBareMemberGraphReferenceFilter(query, lang, exact, contextSql, "f", "r"); + groupedSql += BuildCSharpBareMemberReferenceFilter( + query, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref groupedSql, pathPatterns, excludePathPatterns, excludeTests); groupedSql += $" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {(rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"))}"; groupedSql += " ) grouped_call_sites GROUP BY path, lang, container_kind, container_name, symbol_name"; @@ -538,11 +560,11 @@ FROM symbol_reference_candidates AS identity_candidate /// Find callees used by a caller/container symbol. /// 呼び出し元シンボルが使っている呼び出し先を探す。 /// - public List GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, int offset = 0) - => GetCalleesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, offset, sourceSymbolId: null); + 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) + => GetCalleesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawKinds, rankMode, offset, includeQualifiedCommonCalls, 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, definition.SymbolId); + => GetCalleesCore(definition.Name, limit, definition.Lang, referenceKind: null, pathPatterns, excludePathPatterns, excludeTests, exact: true, rawKinds: false, ReferenceRankMode.Weighted, offset, includeQualifiedCommonCalls: false, sourceSymbolId: definition.SymbolId); private int CountCalleesForCandidate(DefinitionResult definition, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) { @@ -558,10 +580,11 @@ private int CountCalleesForCandidate(DefinitionResult definition, IReadOnlyList< excludeTests, exact: true, rawKinds: false, + includeQualifiedCommonCalls: false, symbolId).Count; } - private List GetCalleesCore(string query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool rawKinds, ReferenceRankMode rankMode, int offset, long? sourceSymbolId) + 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, long? sourceSymbolId) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return new List(); @@ -642,6 +665,8 @@ WHERE r.container_name IS NOT NULL : " AND (r.container_name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND sql_leaf_name(r.container_name) = @aliasQuery COLLATE NOCASE))"; if (lang != null) sql += " AND f.lang = @lang"; + if (!includeQualifiedCommonCalls) + sql += BuildCSharpQualifiedCommonCallNoiseFilter("f", "r"); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += $@" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {referenceSpanLengthSql}, r.reference_kind @@ -739,7 +764,7 @@ FROM ranked_call_sites r return results; } - public int CountCallees(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) + public int CountCallees(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, bool includeQualifiedCommonCalls = false) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return 0; @@ -804,6 +829,8 @@ FROM symbol_references r : " AND (r.container_name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND sql_leaf_name(r.container_name) = @aliasQuery COLLATE NOCASE))"; if (lang != null) groupedSql += " AND f.lang = @lang"; + if (!includeQualifiedCommonCalls) + groupedSql += BuildCSharpQualifiedCommonCallNoiseFilter("f", "r"); AppendPathFilters(ref groupedSql, pathPatterns, excludePathPatterns, excludeTests); groupedSql += $" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {(rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"))}"; groupedSql += " ) grouped_call_sites GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind LIMIT @limit"; @@ -842,7 +869,7 @@ FROM symbol_references r return raw is long l ? (int)l : Convert.ToInt32(raw); } - public QueryCountResult CountCalleesTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false) + public QueryCountResult CountCalleesTotal(string query, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, bool includeQualifiedCommonCalls = false) => CountCalleesTotalCore( query, lang, @@ -852,6 +879,7 @@ public QueryCountResult CountCalleesTotal(string query, string? lang = null, str excludeTests, exact, rawKinds, + includeQualifiedCommonCalls, sourceSymbolId: null); private QueryCountResult CountCalleesTotalCore( @@ -863,6 +891,7 @@ private QueryCountResult CountCalleesTotalCore( bool excludeTests, bool exact, bool rawKinds, + bool includeQualifiedCommonCalls, long? sourceSymbolId) { lang = NormalizeQueryLanguage(lang); @@ -929,6 +958,8 @@ FROM symbol_references r : " AND (r.container_name LIKE @query ESCAPE '\\' OR (f.lang = 'sql' AND sql_leaf_name(r.container_name) = @aliasQuery COLLATE NOCASE))"; if (lang != null) groupedSql += " AND f.lang = @lang"; + if (!includeQualifiedCommonCalls) + groupedSql += BuildCSharpQualifiedCommonCallNoiseFilter("f", "r"); AppendPathFilters(ref groupedSql, pathPatterns, excludePathPatterns, excludeTests); groupedSql += $" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number, {(rawKinds ? GetRawReferenceKindSql("r.reference_kind") : GetLogicalReferenceKindSql("r.reference_kind"))}"; groupedSql += " ) grouped_call_sites GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; @@ -1188,7 +1219,12 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} ? " AND (f.lang = @lang OR f.lang = 'ambiguous_m')" : " AND f.lang = @lang"; } - sql += BuildCSharpBareMemberGraphReferenceFilter(symbolName, lang, exact: true, contextSql, "f", "r"); + sql += BuildCSharpBareMemberReferenceFilter( + symbolName, + lang, + "f", + "r", + includeQualifiedCommonCalls: false); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += @" GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number, source_symbol_id, target_symbol_id diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index d668d7ef3..493586cf3 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -21,7 +21,7 @@ internal sealed record ReferencePositionResolution( /// Search indexed references such as call sites. /// 呼び出し箇所などのインデックス済み参照を検索する。 /// - public List SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false, int offset = 0) + public List SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false, int offset = 0, bool includeQualifiedCommonCalls = false) { maxLineWidth = LineWidthFormatter.ClampMaxLineWidth(maxLineWidth); lang = NormalizeQueryLanguage(lang); @@ -30,7 +30,7 @@ public List SearchReferences(string? query = null, int limit = return new List(); if (!ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(lang, referenceKind, exact)) - return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, maxLineWidth, excludeSelfReferences); + return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, maxLineWidth, excludeSelfReferences, includeQualifiedCommonCalls); var rawLimit = Math.Max(limit, CSharpUsingStaticReferenceFilterChunkSize); var rawOffset = 0; @@ -39,7 +39,7 @@ public List SearchReferences(string? query = null, int limit = var filtered = new List(); while (filtered.Count < limit) { - var rawResults = SearchReferencesCore(query, rawLimit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawOffset, maxLineWidth, excludeSelfReferences); + var rawResults = SearchReferencesCore(query, rawLimit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, rawOffset, maxLineWidth, excludeSelfReferences, includeQualifiedCommonCalls); if (rawResults.Count == 0) break; @@ -70,9 +70,9 @@ public List SearchReferences(string? query = null, int limit = return filtered.Count <= limit ? filtered : filtered.Take(limit).ToList(); } - private List SearchReferencesCore(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset, int maxLineWidth, bool excludeSelfReferences, long? targetSymbolId = null) + private List SearchReferencesCore(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset, int maxLineWidth, bool excludeSelfReferences, bool includeQualifiedCommonCalls, long? targetSymbolId = null) { - using var cmd = CreateSearchReferencesCommandCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, includeOrdering: true, excludeSelfReferences, targetSymbolId); + using var cmd = CreateSearchReferencesCommandCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, includeOrdering: true, excludeSelfReferences, includeQualifiedCommonCalls, targetSymbolId); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) @@ -127,6 +127,7 @@ private List SearchReferencesForCandidate( offset, maxLineWidth, excludeSelfReferences: false, + includeQualifiedCommonCalls: false, targetSymbolId: symbolId); } @@ -151,6 +152,7 @@ private int CountSearchReferencesForCandidate( offset: 0, includeOrdering: false, excludeSelfReferences: false, + includeQualifiedCommonCalls: false, targetSymbolId: symbolId); cmd.CommandText = $"SELECT COUNT(*) FROM ({cmd.CommandText})"; var raw = cmd.ExecuteScalar(); @@ -265,10 +267,10 @@ GROUP BY s.id return new ReferencePositionResolution(true, truncated, candidates); } - private SqliteCommand CreateSearchReferencesCommand(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset = 0, bool includeOrdering = true, bool excludeSelfReferences = false) - => CreateSearchReferencesCommandCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, includeOrdering, excludeSelfReferences, targetSymbolId: null); + private SqliteCommand CreateSearchReferencesCommand(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset = 0, bool includeOrdering = true, bool excludeSelfReferences = false, bool includeQualifiedCommonCalls = false) + => CreateSearchReferencesCommandCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, includeOrdering, excludeSelfReferences, includeQualifiedCommonCalls, targetSymbolId: null); - private SqliteCommand CreateSearchReferencesCommandCore(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset, bool includeOrdering, bool excludeSelfReferences, long? targetSymbolId) + private SqliteCommand CreateSearchReferencesCommandCore(string? query, int limit, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, int offset, bool includeOrdering, bool excludeSelfReferences, bool includeQualifiedCommonCalls, long? targetSymbolId) { var cmd = _conn.CreateCommand(); var referenceLineJoin = ReferenceLineJoinSql("r"); @@ -426,6 +428,12 @@ FROM symbol_reference_candidates AS identity_candidate ? " AND (f.lang = @lang OR f.lang = 'ambiguous_m')" : " AND f.lang = @lang"; } + sql += BuildCSharpBareMemberReferenceFilter( + query ?? string.Empty, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); if (referenceKind == null) { @@ -680,7 +688,7 @@ private static bool IsBareVerbatimQueryToken(string? value) return $"{parentQualifiedName}.{name}"; } - private QueryCountResult CountSearchReferencesTotalWithUsingStaticFilter(string? query, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact) + private QueryCountResult CountSearchReferencesTotalWithUsingStaticFilter(string? query, string? lang, string? referenceKind, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, bool exact, bool includeQualifiedCommonCalls) { if (!_hasReferencesTable) return new QueryCountResult(0, 0); @@ -701,7 +709,8 @@ private QueryCountResult CountSearchReferencesTotalWithUsingStaticFilter(string? excludePathPatterns, excludeTests, exact, - rawOffset); + rawOffset, + includeQualifiedCommonCalls: includeQualifiedCommonCalls); using var reader = cmd.ExecuteTrackedReader(); var rawRows = 0; @@ -727,11 +736,11 @@ private QueryCountResult CountSearchReferencesTotalWithUsingStaticFilter(string? return new QueryCountResult(count, paths.Count, includesSql); } - public int CountSearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false) + public int CountSearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool includeQualifiedCommonCalls = false) { query = NormalizeSymbolSearchQuery(query, lang, exact) ?? query ?? string.Empty; if (ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(lang, referenceKind, exact)) - return SearchReferences(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact).Count; + return SearchReferences(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, includeQualifiedCommonCalls: includeQualifiedCommonCalls).Count; if (!_hasReferencesTable) return 0; using var cmd = _conn.CreateCommand(); @@ -815,6 +824,12 @@ FROM symbol_references r innerSql += " AND r.reference_kind = @referenceKind"; if (lang != null) innerSql += " AND f.lang = @lang"; + innerSql += BuildCSharpBareMemberReferenceFilter( + query ?? string.Empty, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref innerSql, pathPatterns, excludePathPatterns, excludeTests); if (referenceKind == null) innerSql += $" GROUP BY r.file_id, r.symbol_name, r.line, r.column_number, {GetLogicalReferenceKindSql("r.reference_kind")}"; @@ -854,12 +869,12 @@ FROM symbol_references r return raw is long l ? (int)l : Convert.ToInt32(raw); } - public QueryCountResult CountSearchReferencesTotal(string? query = null, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false) + public QueryCountResult CountSearchReferencesTotal(string? query = null, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool includeQualifiedCommonCalls = false) { lang = NormalizeQueryLanguage(lang); query = NormalizeSymbolSearchQuery(query, lang, exact) ?? query ?? string.Empty; if (ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(lang, referenceKind, exact)) - return CountSearchReferencesTotalWithUsingStaticFilter(query, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact); + return CountSearchReferencesTotalWithUsingStaticFilter(query, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, includeQualifiedCommonCalls); if (!_hasReferencesTable) return new QueryCountResult(0, 0); @@ -957,6 +972,12 @@ FROM symbol_references r innerSql += " AND r.reference_kind = @referenceKind"; if (lang != null) innerSql += " AND f.lang = @lang"; + innerSql += BuildCSharpBareMemberReferenceFilter( + query ?? string.Empty, + lang, + "f", + "r", + includeQualifiedCommonCalls); AppendPathFilters(ref innerSql, pathPatterns, excludePathPatterns, excludeTests); if (referenceKind == null) innerSql += $" GROUP BY f.path, f.lang, r.file_id, r.symbol_name, r.line, r.column_number, {GetLogicalReferenceKindSql("r.reference_kind")}"; diff --git a/src/CodeIndex/Database/HotspotReferenceAggregateSql.cs b/src/CodeIndex/Database/HotspotReferenceAggregateSql.cs index 119e8b3e4..d7ffe2b8b 100644 --- a/src/CodeIndex/Database/HotspotReferenceAggregateSql.cs +++ b/src/CodeIndex/Database/HotspotReferenceAggregateSql.cs @@ -1,3 +1,5 @@ +using CodeIndex.Indexer; + namespace CodeIndex.Database; /// @@ -10,6 +12,11 @@ internal static class HotspotReferenceAggregateSql internal const string DeferredDirtyFilesTableName = "temp_hotspot_reference_dirty_files"; internal const string ReferenceKindsSql = DbReader.CallGraphReferenceKindsSql; + private static readonly string CSharpCommonQualifiedMemberCallNamesSql = string.Join( + ", ", + CSharpReferenceExtractor.CommonQualifiedMemberCallNames + .OrderBy(static name => name, StringComparer.Ordinal) + .Select(static name => $"'{name}'")); internal const string CreateTableSql = """ CREATE TABLE IF NOT EXISTS hotspot_reference_counts ( @@ -99,6 +106,13 @@ FROM symbol_references sr WHERE sr.reference_kind IN {ReferenceKindsSql} AND sr.symbol_name IS NOT NULL AND sr.symbol_name <> '' + AND NOT ( + f.lang = 'csharp' + AND sr.reference_kind = 'call' + AND sr.symbol_name IN ({CSharpCommonQualifiedMemberCallNamesSql}) + AND sr.target_qualifier IS NOT NULL + AND COALESCE(sr.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group') + ) AND f.lang != 'sql'{referencePredicate}{testCheckpointPredicate} ), non_sql_logical_sites AS ( diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs index 987987637..51f6c0980 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.cs @@ -28,7 +28,7 @@ internal static partial class CSharpReferenceExtractor private static readonly Regex StaticMemberQualifierRegex = new( @"(?(?:global::)?@?[A-Z_][\p{L}\p{Nd}_]*(?:\.@?[A-Z_][\p{L}\p{Nd}_]*)*)\s*\.\s*@?[\p{L}_][\p{L}\p{Nd}_]*", RegexOptions.Compiled); - private static readonly HashSet CommonQualifiedMemberCallNames = new(StringComparer.Ordinal) + internal static IReadOnlySet CommonQualifiedMemberCallNames { get; } = new HashSet(StringComparer.Ordinal) { "Add", "All", @@ -315,18 +315,6 @@ public static void EmitDocCrefReferences( public static bool IsPatternHeadCallSite(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) => ReferenceExtractor.IsCSharpPatternHeadCallSite(preparedLines, lineIndex, preparedLine, nameIndex); - public static bool ShouldSuppressQualifiedCommonMemberCall(string preparedLine, string normalizedName, int nameIndex) - { - if (!CommonQualifiedMemberCallNames.Contains(normalizedName)) - return false; - - if (!TryGetImmediateMemberAccessQualifier(preparedLine, nameIndex, out var qualifier)) - return false; - - return !string.Equals(qualifier, "this", StringComparison.Ordinal) - && !string.Equals(qualifier, "base", StringComparison.Ordinal); - } - public static void EmitStaticMemberQualifierReferences( string preparedLine, IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, @@ -579,40 +567,6 @@ private static string NormalizeCSharpIdentifier(string identifier) => ? identifier[1..] : identifier; - private static bool TryGetImmediateMemberAccessQualifier(string preparedLine, int nameIndex, out string qualifier) - { - qualifier = string.Empty; - var cursor = nameIndex - 1; - while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) - cursor--; - - if (cursor < 0 || preparedLine[cursor] != '.') - return false; - - cursor--; - while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) - cursor--; - - if (cursor < 0) - return true; - - var end = cursor + 1; - while (cursor >= 0) - { - var ch = preparedLine[cursor]; - if (char.IsLetterOrDigit(ch) || ch == '_' || ch == '@') - { - cursor--; - continue; - } - - break; - } - - qualifier = NormalizeCSharpIdentifier(preparedLine[(cursor + 1)..end]); - return true; - } - public static void EmitQualifiedEnumMemberReferences( string preparedLine, IReadOnlyDictionary> enumMemberLookup, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs index ad7efaee3..4d93b0db5 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs @@ -216,14 +216,6 @@ private static bool TryAddCoreCallLikeReference( sourceLength: name.Length); return true; } - if (line.Language == "csharp" - && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall( - line.PreparedLine, - normalizedName, - callIndex)) - { - return false; - } if (IsIgnoredCallName(line.Language, name) && !(line.Language == "scala" && string.Equals(name, "foreach", StringComparison.Ordinal))) diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index b47bd0c35..2147a523a 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -16,8 +16,8 @@ public partial class McpServer { "search" => new HashSet(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "auditScope", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "tokenBoundary", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "guardScope", "countOnly", "format", "project", "solution" }, "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "visibility", "excludeVisibility", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, - "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rawKinds", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "includeQualifiedCommonCalls", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rawKinds", "includeQualifiedCommonCalls", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "visibility", "excludeVisibility", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "countOnly", "format", "project", "solution" }, "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index ed3ddf177..ef1d5386f 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -106,6 +106,7 @@ private static JsonArray CreateToolCatalog() ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["includeQualifiedCommonCalls"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include unresolved receiver/type-qualified C# calls with common member names. Resolved qualified calls are already included by default.", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact referenced-symbol equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, @@ -125,6 +126,7 @@ private static JsonArray CreateToolCatalog() ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Callee symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default `friend` remains available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, + ["includeQualifiedCommonCalls"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include unresolved receiver/type-qualified C# calls with common member names. Resolved qualified calls are already included by default.", ["default"] = false }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, @@ -152,6 +154,7 @@ private static JsonArray CreateToolCatalog() ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Caller/container symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default graph kinds remain available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, + ["includeQualifiedCommonCalls"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include unresolved receiver/type-qualified C# calls with common member names. Resolved qualified calls are already included by default.", ["default"] = false }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs index 78763249b..fbd7ae464 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs @@ -251,7 +251,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "lspCompatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" or - "rawKinds" or "orderBySize" or "rawBytes" or "byBucket" or "memoryTrace" or "watch" or + "rawKinds" or "includeQualifiedCommonCalls" or "orderBySize" or "rawBytes" or "byBucket" or "memoryTrace" or "watch" or "estimateOnly" or "listRecipes" => "boolean", "project" or "capability" or "scopes" or "fields" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or "commits" or "changedBetween" or "files" or diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index a49a7293d..269c610e3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -41,6 +41,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; + var includeQualifiedCommonCalls = args?["includeQualifiedCommonCalls"]?.GetValue() ?? false; if (!TryResolveNameExactArgument(args, "references", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); @@ -48,9 +49,9 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) { if (countOnly) { - var countOnlyTotal = reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; + var countOnlyTotal = reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, includeQualifiedCommonCalls).Count; var histogramResults = countOnlyTotal > 0 - ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth) + ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, includeQualifiedCommonCalls: includeQualifiedCommonCalls) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; @@ -58,6 +59,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + countOnlyPayload["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls; AddHdlGraphContractSignal( countOnlyPayload, reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); @@ -65,10 +67,10 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); } - var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); + var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset, includeQualifiedCommonCalls: includeQualifiedCommonCalls); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count + ? reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, includeQualifiedCommonCalls).Count : results.Count; if (lspCompatible) QueryCommandRunner.AttachLspLocations(results); @@ -81,9 +83,9 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) var exactSignal = reader.GetReferencesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountSearchReferences(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false) > 0, - () => reader.CountSearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), - () => reader.SearchReferences(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), + () => reader.CountSearchReferences(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, includeQualifiedCommonCalls: includeQualifiedCommonCalls) > 0, + () => reader.CountSearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, includeQualifiedCommonCalls: includeQualifiedCommonCalls), + () => reader.SearchReferences(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, includeQualifiedCommonCalls: includeQualifiedCommonCalls), r => r.SymbolName); var payload = new JsonObject { @@ -94,6 +96,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, + ["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls, ["graph_language"] = graphSupport.GraphLanguage, ["graph_supported"] = graphSupport.GraphSupported, ["graph_support_reason"] = graphSupport.GraphSupportReason, @@ -158,19 +161,21 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; var rawKinds = args?["rawKinds"]?.GetValue() ?? false; + var includeQualifiedCommonCalls = args?["includeQualifiedCommonCalls"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { if (countOnly) { - var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; + var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls).Count; var histogramResults = countOnlyTotal > 0 - ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) + ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; countOnlyPayload["kind"] = kind; countOnlyPayload["rawKinds"] = rawKinds; + countOnlyPayload["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls; countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; @@ -185,10 +190,10 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); } - var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); + var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset, includeQualifiedCommonCalls: includeQualifiedCommonCalls); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count + ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( @@ -199,15 +204,16 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) var exactSignal = reader.GetCallersExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, - () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), - () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), + () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls) > 0, + () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls), + () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls), r => r.CalleeName); var payload = new JsonObject { ["query"] = query, ["kind"] = kind, ["rawKinds"] = rawKinds, + ["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls, ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, @@ -281,19 +287,21 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; var rawKinds = args?["rawKinds"]?.GetValue() ?? false; + var includeQualifiedCommonCalls = args?["includeQualifiedCommonCalls"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { if (countOnly) { - var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; + var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls).Count; var histogramResults = countOnlyTotal > 0 - ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) + ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; countOnlyPayload["kind"] = kind; countOnlyPayload["rawKinds"] = rawKinds; + countOnlyPayload["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls; countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; @@ -308,10 +316,10 @@ 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); + var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset, includeQualifiedCommonCalls: includeQualifiedCommonCalls); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count + ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, includeQualifiedCommonCalls).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( @@ -322,15 +330,16 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) var exactSignal = reader.GetCalleesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, - () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), - () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), + () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls) > 0, + () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, includeQualifiedCommonCalls: includeQualifiedCommonCalls), + () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode, includeQualifiedCommonCalls: includeQualifiedCommonCalls), r => r.CallerName); var payload = new JsonObject { ["query"] = query, ["kind"] = kind, ["rawKinds"] = rawKinds, + ["includeQualifiedCommonCalls"] = includeQualifiedCommonCalls, ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index 10de6f8cb..e866d751c 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -136,6 +136,19 @@ public void Goto_AcceptsDocumentedExcludeFilters_Issue3934() Assert.Contains("--exclude-path", accepted); } + [Fact] + public void QualifiedCommonCallCompletenessFlag_IsScopedToGraphCommands_Issue4867() + { + const string flag = "--include-qualified-common-calls"; + foreach (var command in new[] { "references", "callers", "callees" }) + { + Assert.Contains(flag, CliFlagSchema.GetAcceptedFlagNamesForCommand(command)); + Assert.Contains(CliFlagSchema.GetCompletionFlagsForCommand(command), option => option.Name == flag); + } + + Assert.DoesNotContain(flag, CliFlagSchema.GetAcceptedFlagNamesForCommand("search")); + } + [Fact] public void AuditAggregationFlags_SurfaceDocumentedRecipeGrouping_Issues4301_4339() { diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 8645319ac..ce543dd15 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -406,7 +406,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx callees |--query |-- ", output); AssertSearchUsageFragments(output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); - Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); + Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]", output); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 8cd0a8896..dcd1757e4 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -880,6 +880,7 @@ private SqliteCommand CreateSearchReferencesCommandForSql(string query) 0, true, false, + false, ])); } @@ -3127,10 +3128,18 @@ public void Run() } [Fact] - public void GraphQueries_CsharpBareMemberCallersSkipReceiverQualifiedCalls() + public void GraphQueries_CsharpQualifiedCommonCalls_UseResolutionAwareDefaultsAndCompletenessOptIn_Issue4867() { + InsertIndexedFile("src/common_member_graph_target.cs", "csharp", + """ + public class LocalApi + { + public string GetString() => ""; + } + """); InsertIndexedFile("src/common_member_graph_fixture.cs", "csharp", """ + using System; using System.Text.Json; public class Caller @@ -3141,25 +3150,171 @@ public void Run(LocalApi api, JsonElement json) { api.GetString(); json.GetString(); - GetString(); + this.GetString(); } - } - public class LocalApi - { - public string GetString() => ""; + public void Noise(JsonElement json) => json.GetString(); + + public int Parse(string value) + { + if (value.Length <= 1) + return int.Parse(value); + return Parse(value[1..]); + } + + public int Parse(ReadOnlySpan value) => int.Parse(value); } """); - var callers = _reader.GetCallers("GetString", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"]); + var defaultReferences = _reader.SearchReferences( + "GetString", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"]); + var completeReferences = _reader.SearchReferences( + "GetString", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true); + + Assert.Equal(2, defaultReferences.Count); + Assert.Equal(4, completeReferences.Count); + Assert.Equal( + 2, + _reader.CountSearchReferences( + "GetString", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"])); + Assert.Equal( + 4, + _reader.CountSearchReferences( + "GetString", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true)); + Assert.DoesNotContain(defaultReferences, reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)); + var unresolvedReceiverCalls = completeReferences + .Where(reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)) + .ToList(); + Assert.Equal(2, unresolvedReceiverCalls.Count); + Assert.All( + unresolvedReceiverCalls, + reference => Assert.Equal("unresolved", reference.ResolutionState)); + Assert.Contains( + defaultReferences, + reference => reference.Context.Contains("api.GetString", StringComparison.Ordinal) + && reference.ResolutionState is "resolved" or "resolved_group"); + + var qualifiedParse = _reader.SearchReferences( + "int.Parse", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"]); + Assert.Single(qualifiedParse); + Assert.All(qualifiedParse, reference => + { + Assert.Equal("Parse", reference.SymbolName); + Assert.Equal("Parse", reference.ContainerName); + }); + var defaultParse = Assert.Single(_reader.SearchReferences( + "Parse", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"])); + Assert.Contains("return Parse(", defaultParse.Context, StringComparison.Ordinal); + Assert.Equal(3, _reader.SearchReferences( + "Parse", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true).Count); + + var callers = _reader.GetCallers( + "GetString", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"]); + var completeCallers = _reader.GetCallers( + "GetString", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true); var caller = Assert.Single(callers); Assert.Equal("Run", caller.CallerName); - Assert.Equal(1, caller.ReferenceCount); + Assert.Equal(2, caller.ReferenceCount); + Assert.Equal(3, Assert.Single(completeCallers, result => result.CallerName == "Run").ReferenceCount); + Assert.Equal(1, Assert.Single(completeCallers, result => result.CallerName == "Noise").ReferenceCount); Assert.Equal(1, _reader.CountCallers("GetString", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"])); + Assert.Equal( + 2, + _reader.CountCallers( + "GetString", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true)); var total = _reader.CountCallersTotal("GetString", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"]); Assert.Equal(1, total.Count); Assert.Equal(1, total.FileCount); + + var defaultCallee = Assert.Single( + _reader.GetCallees("Run", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"]), + callee => callee.CalleeName == "GetString"); + var completeCallee = Assert.Single( + _reader.GetCallees( + "Run", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true), + callee => callee.CalleeName == "GetString"); + Assert.Equal(2, defaultCallee.ReferenceCount); + Assert.Equal(3, completeCallee.ReferenceCount); + Assert.Empty(_reader.GetCallees( + "Noise", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"])); + Assert.Single(_reader.GetCallees( + "Noise", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true)); + var defaultParseCallee = Assert.Single( + _reader.GetCallees("Parse", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"])); + var completeParseCallee = Assert.Single(_reader.GetCallees( + "Parse", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true)); + Assert.Equal(1, defaultParseCallee.ReferenceCount); + Assert.Equal(3, completeParseCallee.ReferenceCount); + + var dependency = Assert.Single(_reader.GetFileDependencies( + limit: 10, + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"], + excludePathPatterns: null, + excludeTests: false)); + Assert.Equal("src/common_member_graph_fixture.cs", dependency.SourcePath); + Assert.Equal("src/common_member_graph_target.cs", dependency.TargetPath); + Assert.Equal(2, dependency.ReferenceCount); + + var hotspot = Assert.Single( + _reader.GetSymbolHotspots( + 10, + "function", + "csharp", + ["src/*common_member_graph_fixture*"], + null, + false), + result => result.Symbol.Name == "GetString"); + Assert.Equal(1, hotspot.ReferenceCount); } diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index a5835404c..dfb08855d 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -289,10 +289,11 @@ public void ToolsCall_SymbolGraphAndAnalyzeExposeCliQueryOptions_Issue3542() Assert.Equal("private", Assert.Single(symbolsStructured["excludeVisibility"]!.AsArray())!.GetValue()); var callersRequest = JsonNode.Parse( - """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"RunVisible","rawKinds":true,"format":"count"}}}""")!; + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"RunVisible","rawKinds":true,"includeQualifiedCommonCalls":true,"format":"count"}}}""")!; var callersResponse = _server.HandleMessage(callersRequest)!; var callersStructured = callersResponse["result"]!["structuredContent"]!; Assert.True(callersStructured["rawKinds"]!.GetValue()); + Assert.True(callersStructured["includeQualifiedCommonCalls"]!.GetValue()); Assert.True(callersStructured["count_only"]!.GetValue()); var analyzeRequest = JsonNode.Parse( diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index 8a9c8a730..f1c650819 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -37,6 +37,22 @@ public void ToolsList_IndexPathSchemaReflectsProjectPathContract_Issue3186() Assert.Contains("absolute or relative", pathSchema["description"]!.GetValue(), StringComparison.Ordinal); } + [Fact] + public void ToolsList_QualifiedCommonCallCompletenessOption_IsScopedToGraphTools_Issue4867() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + var tools = response["result"]!["tools"]!.AsArray(); + + foreach (var toolName in new[] { "references", "callers", "callees" }) + { + var tool = tools.First(candidate => candidate!["name"]!.GetValue() == toolName)!; + var option = tool["inputSchema"]!["properties"]!["includeQualifiedCommonCalls"]!; + Assert.Equal("boolean", option["type"]!.GetValue()); + Assert.False(option["default"]!.GetValue()); + } + } + [Fact] public void ToolsList_EachToolPublishesSchemaAndExampleContract() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index 2be72e61c..f153ee512 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -187,7 +187,7 @@ static void Run(Wrapper byRef, Payload input) } [Fact] - public void Extract_CSharpQualifiedDataReaderCall_DoesNotEmitCommonMemberReference_Issue4121() + public void Extract_CSharpQualifiedDataReaderCall_RetainsReceiverEvidence_Issues4121And4867() { const string content = """ class ReaderAdapter @@ -208,9 +208,9 @@ void Run(Microsoft.Data.Sqlite.SqliteDataReader reader) .Where(reference => reference.SymbolName == "GetInt32" && reference.ReferenceKind == "call") .ToList(); - var call = Assert.Single(getInt32Calls); - Assert.Contains("this.GetInt32()", call.Context, StringComparison.Ordinal); - Assert.DoesNotContain(getInt32Calls, reference => reference.Context.Contains("reader.GetInt32", StringComparison.Ordinal)); + Assert.Equal(2, getInt32Calls.Count); + Assert.Contains(getInt32Calls, reference => reference.Context.Contains("this.GetInt32()", StringComparison.Ordinal)); + Assert.Contains(getInt32Calls, reference => reference.Context.Contains("reader.GetInt32", StringComparison.Ordinal)); } [Fact] @@ -736,18 +736,30 @@ public int FibBlock(int n) } [Fact] - public void Extract_CsharpCommonQualifiedMemberCalls_DoNotOvermatchLocalFunctions_Issue3894() + public void Extract_CsharpQualifiedCommonMemberCalls_AreRetainedWithoutLocalOvermatch_Issues3894And4867() { const string content = """ using System; + using System.Collections.Generic; using System.IO; + using System.Linq; using System.Text.Json; + using IntAlias = System.Int32; public class WorkerProcessCleanupDiagnostics { private string Combine(string left, string right) => left + right; + public int Parse(string value) + { + if (value.Length <= 1) + return int.Parse(value); + return Parse(value[1..]); + } - public void TryKill(JsonElement element, string value) + public int Parse(ReadOnlySpan value) => int.Parse(value); + public bool TryParseAlias(string value) => IntAlias.TryParse(value, out _); + + public void TryKill(JsonElement element, string value, IEnumerable items) { Combine("a", "b"); thisCombine(); @@ -756,6 +768,9 @@ public void TryKill(JsonElement element, string value) var joined = string.Join(",", new[] { "a", "b" }); var text = element.GetString(); value.Replace("a", "b"); + var materialized = items.Where(item => item > 0).Select(item => item + 1).ToList(); + Parse(value); + Parse(value.AsSpan()); this.Combine("c", "d"); } @@ -769,9 +784,23 @@ private static void thisCombine() { } .Where(reference => reference.SymbolName == "Combine" && reference.ReferenceKind == "call") .ToList(); - Assert.Equal(2, combineCalls.Count); + Assert.Equal(3, combineCalls.Count); + Assert.Equal(2, combineCalls.Count(reference => !reference.Context.Contains("Path.Combine", StringComparison.Ordinal))); Assert.All(combineCalls, reference => Assert.Equal("TryKill", reference.ContainerName)); - Assert.DoesNotContain(references, reference => reference.SymbolName is "Max" or "Join" or "GetString" or "Replace"); + Assert.Equal( + 3, + references.Count(reference => reference is { SymbolName: "Parse", ReferenceKind: "call", ContainerName: "Parse" })); + Assert.Equal( + 2, + references.Count(reference => reference is { SymbolName: "Parse", ReferenceKind: "call", ContainerName: "TryKill" })); + Assert.Contains(references, reference => reference is { SymbolName: "TryParse", ReferenceKind: "call", ContainerName: "TryParseAlias" }); + Assert.Contains(references, reference => reference is { SymbolName: "Max", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "Join", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "GetString", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "Replace", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "Where", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "Select", ReferenceKind: "call", ContainerName: "TryKill" }); + Assert.Contains(references, reference => reference is { SymbolName: "ToList", ReferenceKind: "call", ContainerName: "TryKill" }); } [Fact] From 91e15f68e51f36680bf1e5010a04bcb128caefd1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:14:31 +0900 Subject: [PATCH 2/3] Address qualified-call upgrade compatibility (#4867) --- changelog.d/unreleased/4867.fixed.md | 4 +- .../Database/DbReader.GraphQueries.cs | 23 +++- .../Symbols/SymbolExtractor.Contracts.cs | 2 +- tests/CodeIndex.Tests/DbReaderTests.cs | 9 ++ .../IndexCommandRunnerFullScanTests.cs | 67 +++++++++++ .../LegacySchemaMigrationTests.cs | 104 ++++++++++++++++++ 6 files changed, 202 insertions(+), 7 deletions(-) diff --git a/changelog.d/unreleased/4867.fixed.md b/changelog.d/unreleased/4867.fixed.md index b4faca61c..1c3a1a946 100644 --- a/changelog.d/unreleased/4867.fixed.md +++ b/changelog.d/unreleased/4867.fixed.md @@ -15,8 +15,8 @@ affected: ## English -- **C# qualified common-name calls are retained and filtered by resolution evidence (#4867)** — Indexing now preserves static BCL, instance, extension, aliased, and unresolved qualified calls such as `int.Parse`, `Where`, `Select`, `ToList`, `Read`, and `Write`. Bare-name `references`, `callers`, `callees`, and hotspot defaults suppress only unresolved qualified noise while retaining resolved calls; `--include-qualified-common-calls` and MCP `includeQualifiedCommonCalls` expose the complete deterministic evidence without creating unresolved dependency edges. +- **C# qualified common-name calls are retained and filtered by resolution evidence (#4867)** — Indexing now preserves static BCL, instance, extension, aliased, and unresolved qualified calls such as `int.Parse`, `Where`, `Select`, `ToList`, `Read`, and `Write`; the C# extractor contract refreshes unchanged files from older indexes on the next full scan. Bare-name `references`, `callers`, `callees`, and hotspot defaults suppress only unresolved qualified noise while retaining resolved calls; `--include-qualified-common-calls` and MCP `includeQualifiedCommonCalls` expose the complete deterministic evidence without creating unresolved dependency edges. ## 日本語 -- **C# の修飾付き一般名 call を保持し、解決 evidence に基づいて絞り込むようにしました (#4867)** — indexing は `int.Parse`、`Where`、`Select`、`ToList`、`Read`、`Write` など、static BCL、instance、extension、alias 修飾、未解決の修飾 call を保持します。無修飾名による `references`、`callers`、`callees` と hotspot の既定動作は解決済み call を維持し、未解決の修飾 noise だけを除外します。`--include-qualified-common-calls` と MCP の `includeQualifiedCommonCalls` で、未解決 dependency edge を作らずに完全で決定的な evidence を取得できます。 +- **C# の修飾付き一般名 call を保持し、解決 evidence に基づいて絞り込むようにしました (#4867)** — indexing は `int.Parse`、`Where`、`Select`、`ToList`、`Read`、`Write` など、static BCL、instance、extension、alias 修飾、未解決の修飾 call を保持し、C# extractor contract により旧 index の未変更ファイルも次回の full scan で更新します。無修飾名による `references`、`callers`、`callees` と hotspot の既定動作は解決済み call を維持し、未解決の修飾 noise だけを除外します。`--include-qualified-common-calls` と MCP の `includeQualifiedCommonCalls` で、未解決 dependency edge を作らずに完全で決定的な evidence を取得できます。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index c31f48492..ab5a9277a 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -13,7 +13,7 @@ public partial class DbReader .OrderBy(static name => name, StringComparer.Ordinal) .Select(static name => $"'{name}'")); - private static string BuildCSharpBareMemberReferenceFilter( + private string BuildCSharpBareMemberReferenceFilter( string query, string? lang, string fileAlias, @@ -27,10 +27,23 @@ private static string BuildCSharpBareMemberReferenceFilter( return BuildCSharpQualifiedCommonCallNoiseFilter(fileAlias, referenceAlias); } - private static string BuildCSharpQualifiedCommonCallNoiseFilter( + private string BuildCSharpQualifiedCommonCallNoiseFilter( string fileAlias, string referenceAlias) - => $" AND NOT ({fileAlias}.lang = 'csharp' AND {referenceAlias}.reference_kind = 'call' AND {referenceAlias}.symbol_name IN ({CSharpCommonQualifiedMemberCallNamesSql}) AND {referenceAlias}.target_qualifier IS NOT NULL AND COALESCE({referenceAlias}.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group'))"; + { + // Legacy read-only indexes cannot run the migrations that added resolution + // evidence. Preserve their pre-filter graph behavior instead of emitting SQL + // against columns they do not have. + // 読み取り専用の旧 index は resolution evidence 列を追加できないため、 + // 存在しない列を参照せず従来の graph 結果へフォールバックする。 + if (!_referenceColumns.Contains("target_qualifier") + || !_referenceColumns.Contains("resolution_state")) + { + return string.Empty; + } + + return $" AND NOT ({fileAlias}.lang = 'csharp' AND {referenceAlias}.reference_kind = 'call' AND {referenceAlias}.symbol_name IN ({CSharpCommonQualifiedMemberCallNamesSql}) AND {referenceAlias}.target_qualifier IS NOT NULL AND COALESCE({referenceAlias}.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group'))"; + } private static bool ShouldFilterCSharpQualifiedCommonBareMemberQuery(string query, string? lang) { @@ -45,7 +58,9 @@ private static bool ShouldFilterCSharpQualifiedCommonBareMemberQuery(string quer return false; return value.All(c => char.IsLetterOrDigit(c) || c == '_') - && CSharpReferenceExtractor.CommonQualifiedMemberCallNames.Contains(value); + && CSharpReferenceExtractor.CommonQualifiedMemberCallNames.Contains( + value, + StringComparer.OrdinalIgnoreCase); } /// diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index 7d54d2e00..43c4cada5 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -5,7 +5,7 @@ public static partial class SymbolExtractor public const int DefaultContractVersion = 1; public const int ExpandedLanguageContractVersion = 2; public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 7; + public const int CSharpContractVersion = 8; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index dcd1757e4..903063713 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -3193,6 +3193,15 @@ public int Parse(string value) exact: true, pathPatterns: ["src/*common_member_graph_fixture*"], includeQualifiedCommonCalls: true)); + var caseInsensitiveReferences = _reader.SearchReferences( + "getstring", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"]); + Assert.Equal(2, caseInsensitiveReferences.Count); + Assert.DoesNotContain( + caseInsensitiveReferences, + reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)); Assert.DoesNotContain(defaultReferences, reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)); var unresolvedReceiverCalls = completeReferences .Where(reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index c5b5beb0c..8a5c0860e 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -4519,6 +4519,73 @@ WHERE kind IN ('route', 'implements', 'attribute', 'layout') } } + [Fact] + public void Run_FullScan_ReextractsQualifiedCommonCallsFromVersion7CSharpIndex_Issue4867() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText( + Path.Combine(projectRoot, "Caller.cs"), + """ + using System.Text.Json; + + public class Caller + { + public void Run(JsonElement json) => json.GetString(); + } + """); + + Assert.Equal( + CommandExitCodes.Success, + IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions)); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var conn = OpenNonPoolingConnection(dbPath)) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + DELETE FROM symbol_references + WHERE symbol_name = 'GetString'; + UPDATE codeindex_meta + SET value = '7' + WHERE key = '{DbContext.GetSymbolExtractorVersionMetaKey("csharp")}'; + """; + cmd.ExecuteNonQuery(); + } + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("files_skipped").GetInt32()); + + using var verify = OpenNonPoolingConnection(dbPath); + verify.Open(); + using var referenceCmd = verify.CreateCommand(); + referenceCmd.CommandText = """ + SELECT COUNT(*) + FROM symbol_references + WHERE symbol_name = 'GetString' + """; + Assert.Equal(1L, referenceCmd.ExecuteScalar()); + + using var versionCmd = verify.CreateCommand(); + versionCmd.CommandText = + $"SELECT value FROM codeindex_meta WHERE key = '{DbContext.GetSymbolExtractorVersionMetaKey("csharp")}'"; + Assert.Equal( + SymbolExtractor.CSharpContractVersion.ToString( + System.Globalization.CultureInfo.InvariantCulture), + versionCmd.ExecuteScalar() as string); + Assert.Equal(8, SymbolExtractor.CSharpContractVersion); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_FullScan_StampsRefreshedDynamicGraphContractWhenFoldContractRemainsStale_Issue4746() { diff --git a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs index 2b070d9fd..35d7b27b2 100644 --- a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +++ b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs @@ -754,6 +754,110 @@ public void DbContext_ReadOnlyFilesystem_FallsBackToReadOnlyOpen() } } + [Fact] + public void GraphQueries_ReadOnlyLegacyIdentityColumnsMissing_PreserveFallback_Issue4867() + { + var dir = TestProjectHelper.CreateTempProject("codeindex_legacy_graph_identity"); + var dbPath = Path.Combine(dir, "codeindex.db"); + try + { + using (var seed = new SqliteConnection( + new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString)) + { + seed.Open(); + Exec(seed, $"PRAGMA application_id = {DbContext.ApplicationId}"); + Exec(seed, $"PRAGMA user_version = {DbContext.GraphReadyFlag}"); + Exec(seed, """ + CREATE TABLE files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL, + lang TEXT, + lines INTEGER + ) + """); + Exec(seed, """ + CREATE TABLE chunks ( + id INTEGER PRIMARY KEY, + file_id INTEGER, + content TEXT + ) + """); + Exec(seed, """ + CREATE TABLE symbols ( + id INTEGER PRIMARY KEY, + file_id INTEGER, + name TEXT, + kind TEXT, + line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + end_line INTEGER, + signature TEXT + ) + """); + Exec(seed, """ + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY, + file_id INTEGER, + symbol_name TEXT, + reference_kind TEXT, + line INTEGER, + column_number INTEGER, + context TEXT, + container_kind TEXT, + container_name TEXT + ) + """); + Exec(seed, "INSERT INTO files (id, path, lang) VALUES (1, 'src/LegacyGraph.cs', 'csharp')"); + Exec(seed, """ + INSERT INTO symbol_references ( + id, + file_id, + symbol_name, + reference_kind, + line, + column_number, + context, + container_kind, + container_name + ) + VALUES ( + 1, + 1, + 'GetString', + 'call', + 1, + 28, + 'void Run() { json.GetString(); }', + 'function', + 'Run' + ) + """); + } + + SqliteConnection.ClearAllPools(); + var fileUri = new Uri(dbPath).AbsoluteUri + "?immutable=1"; + using var db = new DbContext(DbOpenIntent.QueryOnly, fileUri); + Assert.True(db.IsReadOnly); + db.TryMigrateForRead(); + + var reader = new DbReader(db); + var reference = Assert.Single( + reader.SearchReferences("GetString", lang: "csharp", exact: true)); + Assert.Equal("Run", reference.ContainerName); + var caller = Assert.Single( + reader.GetCallers("GetString", lang: "csharp", exact: true)); + Assert.Equal("Run", caller.CallerName); + var callee = Assert.Single( + reader.GetCallees("Run", lang: "csharp", exact: true)); + Assert.Equal("GetString", callee.CalleeName); + } + finally + { + DeleteDirectoryAfterClearingPools(dir); + } + } + [Fact] public void ReadOnlyFallbackDiagnostics_AddsStaleWalRiskToQueryPayload() { From 409574ab69f98bc19473ccb1b41143a3b125ddcc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:47:33 +0900 Subject: [PATCH 3/3] Fix qualified-call review regressions (#4867) --- .../Database/DbReader.GraphQueries.cs | 16 +--- .../DbWriter.ReferenceGraphRefreshScope.cs | 25 ++++++ src/CodeIndex/Database/DbWriter.References.cs | 47 +++++++++-- tests/CodeIndex.Tests/DbReaderTests.cs | 62 ++++++++++++--- .../IndexCommandRunnerFullScanTests.cs | 78 +++++++++++++++++++ 5 files changed, 197 insertions(+), 31 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index ab5a9277a..0f3ff62da 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -47,20 +47,8 @@ private string BuildCSharpQualifiedCommonCallNoiseFilter( private static bool ShouldFilterCSharpQualifiedCommonBareMemberQuery(string query, string? lang) { - if (lang is not null and not "csharp" || SqlNameResolver.HasQualifier(query)) - return false; - - if (string.IsNullOrWhiteSpace(query)) - return false; - - var value = query[0] == '@' ? query[1..] : query; - if (value.Length == 0 || !(char.IsLetter(value[0]) || value[0] == '_')) - return false; - - return value.All(c => char.IsLetterOrDigit(c) || c == '_') - && CSharpReferenceExtractor.CommonQualifiedMemberCallNames.Contains( - value, - StringComparer.OrdinalIgnoreCase); + return (lang == null || string.Equals(lang, "csharp", StringComparison.OrdinalIgnoreCase)) + && !SqlNameResolver.HasQualifier(query); } /// diff --git a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs index 4ba13cce6..e61a96d2b 100644 --- a/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs +++ b/src/CodeIndex/Database/DbWriter.ReferenceGraphRefreshScope.cs @@ -671,6 +671,31 @@ private int CountReferenceGraphRows(string tableName, CancellationToken cancella System.Globalization.CultureInfo.InvariantCulture)); } + private HashSet GetReferenceGraphRefreshFileIds( + bool useFullRefresh, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var command = _conn.CreateCommand(); + command.Transaction = _activeTransaction; + command.CommandText = useFullRefresh + ? "SELECT DISTINCT file_id FROM symbol_references" + : $""" + SELECT DISTINCT reference.file_id + FROM temp.{ReferenceGraphDirtyReferencesTable} AS dirty + JOIN symbol_references AS reference ON reference.id = dirty.reference_id + """; + using var cancellationRegistration = RegisterSqliteInterrupt(cancellationToken); + var fileIds = new HashSet(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + fileIds.Add(reader.GetInt64(0)); + } + return fileIds; + } + private void ExecuteReferenceGraphScopeSql(string sql, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index c26de8588..22739fcfc 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -7,6 +7,8 @@ public partial class DbWriter { internal static Action? HotspotAggregateRefreshExecutingForTesting { get; set; } private const string NonTypeReceiverQualifierPrefix = "\u001freceiver:"; + private const string NonIdentifierReceiverQualifier = + NonTypeReceiverQualifierPrefix + "\u001fqualified"; private const int MaxReferenceLineWindowBatchCount = 32; private const string MutualRecursionValueSql = """ @@ -1974,7 +1976,8 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = if (graphScope != null) graphScope.IsCompleting = true; SqliteCommand? createUniqueFamiliesCommand = null; - SqliteCommand? refreshCommand = null; + SqliteCommand? refreshIdentityCommand = null; + SqliteCommand? refreshMutualCommand = null; try { using var cancellationRegistration = RegisterSqliteInterrupt(cancellationToken); @@ -2022,17 +2025,31 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = RefreshScopedReferenceResolutionSql + "\n" + ExpandReferenceGraphNewMutualScopeSql + "\n"; } - refreshCommand = RentCommand( - refreshIdentitySql + (refreshPlan.UseFullRefresh + var hotspotReferenceFileIds = GetReferenceGraphRefreshFileIds( + refreshPlan.UseFullRefresh, + cancellationToken); + refreshIdentityCommand = RentCommand(refreshIdentitySql, static _ => { }); + refreshMutualCommand = RentCommand( + refreshPlan.UseFullRefresh ? RefreshMutualRecursionFlagsSql - : RefreshScopedMutualRecursionFlagsSql), + : RefreshScopedMutualRecursionFlagsSql, static _ => { }); // Stamp inside the same transaction, but before the graph refresh so the // public SQLite changes() result continues to describe recursion updates. // 同一トランザクション内で先に marker を設定し、公開 changes() は再帰更新件数を維持する。 MarkReferenceIdentityContractReady(); cancellationToken.ThrowIfCancellationRequested(); - refreshCommand.ExecuteNonQuery(); + refreshIdentityCommand.ExecuteNonQuery(); + cancellationToken.ThrowIfCancellationRequested(); + // Resolution changes alter the default C# common-call hotspot projection even + // when the caller file itself was skipped. Refresh those source-file aggregates + // before the recursion statement, which preserves the public changes() contract. + // resolution の変更は caller file 自体が skip されても C# common-call の既定 + // hotspot projection を変えるため、公開 changes() 契約を保つ recursion 文の前に + // 対象 source file の aggregate を再集計する。 + RefreshHotspotReferenceCounts(hotspotReferenceFileIds, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + refreshMutualCommand.ExecuteNonQuery(); cancellationToken.ThrowIfCancellationRequested(); if (graphScope != null) ExecuteReferenceGraphScopeSql(ClearReferenceGraphDirtyScopeSql, cancellationToken); @@ -2045,8 +2062,10 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = } finally { - if (refreshCommand != null) - ReleaseCommand(refreshCommand); + if (refreshMutualCommand != null) + ReleaseCommand(refreshMutualCommand); + if (refreshIdentityCommand != null) + ReleaseCommand(refreshIdentityCommand); if (createUniqueFamiliesCommand != null) ReleaseCommand(createUniqueFamiliesCommand); if (graphScope != null) @@ -2096,12 +2115,24 @@ internal void RefreshMutualRecursionFlags(CancellationToken cancellationToken = var end = dot - 1; while (end >= 0 && char.IsWhiteSpace(context[end])) end--; + // Preserve a useful simple receiver through null-conditional/null-forgiving + // punctuation (`json?.Read()` / `json!.Read()`). More complex receivers receive a + // conservative non-null marker below so they can never enter the global fallback. + // null conditional / null forgiving の句読点(`json?.Read()` / `json!.Read()`)を + // 越えて単純 receiver を保持する。複雑な receiver は下で保守的な non-null marker + // を付け、global fallback に入らないようにする。 + while (end >= 0 && context[end] is '?' or '!') + { + end--; + while (end >= 0 && char.IsWhiteSpace(context[end])) + end--; + } var start = end; while (start >= 0 && (char.IsLetterOrDigit(context[start]) || context[start] is '_' or '@')) start--; var qualifier = context[(start + 1)..(end + 1)].TrimStart('@'); if (qualifier.Length == 0) - return null; + return NonIdentifierReceiverQualifier; // `this.Member()` is genuinely unqualified with respect to the current container. // Other lowercase receivers (for example `service.Process()`) need a non-null marker // so the global fallback stays disabled. The resolver may recover a target container diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 903063713..931909069 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -3153,7 +3153,8 @@ public void Run(LocalApi api, JsonElement json) this.GetString(); } - public void Noise(JsonElement json) => json.GetString(); + public void Noise(JsonElement? json) => json?.GetString(); + public void Complex(JsonElement json) => (json).GetString(); public int Parse(string value) { @@ -3177,7 +3178,20 @@ public int Parse(string value) includeQualifiedCommonCalls: true); Assert.Equal(2, defaultReferences.Count); - Assert.Equal(4, completeReferences.Count); + Assert.Equal(5, completeReferences.Count); + Assert.Equal( + 2, + _reader.SearchReferences( + "GetStr", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"]).Count); + Assert.Equal( + 5, + _reader.SearchReferences( + "GetStr", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true).Count); Assert.Equal( 2, _reader.CountSearchReferences( @@ -3186,7 +3200,7 @@ public int Parse(string value) exact: true, pathPatterns: ["src/*common_member_graph_fixture*"])); Assert.Equal( - 4, + 5, _reader.CountSearchReferences( "GetString", lang: "csharp", @@ -3201,12 +3215,17 @@ public int Parse(string value) Assert.Equal(2, caseInsensitiveReferences.Count); Assert.DoesNotContain( caseInsensitiveReferences, - reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)); - Assert.DoesNotContain(defaultReferences, reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)); + reference => reference.Context.Contains("json", StringComparison.Ordinal) + && reference.Context.Contains("GetString", StringComparison.Ordinal)); + Assert.DoesNotContain( + defaultReferences, + reference => reference.Context.Contains("json", StringComparison.Ordinal) + && reference.Context.Contains("GetString", StringComparison.Ordinal)); var unresolvedReceiverCalls = completeReferences - .Where(reference => reference.Context.Contains("json.GetString", StringComparison.Ordinal)) + .Where(reference => reference.Context.Contains("json", StringComparison.Ordinal) + && reference.Context.Contains("GetString", StringComparison.Ordinal)) .ToList(); - Assert.Equal(2, unresolvedReceiverCalls.Count); + Assert.Equal(3, unresolvedReceiverCalls.Count); Assert.All( unresolvedReceiverCalls, reference => Assert.Equal("unresolved", reference.ResolutionState)); @@ -3250,15 +3269,29 @@ public int Parse(string value) exact: true, pathPatterns: ["src/*common_member_graph_fixture*"], includeQualifiedCommonCalls: true); + var partialCallers = _reader.GetCallers( + "GetStr", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"]); + var completePartialCallers = _reader.GetCallers( + "GetStr", + lang: "csharp", + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true); var caller = Assert.Single(callers); Assert.Equal("Run", caller.CallerName); Assert.Equal(2, caller.ReferenceCount); + Assert.Equal(2, Assert.Single(partialCallers).ReferenceCount); + Assert.Equal(3, Assert.Single(completePartialCallers, result => result.CallerName == "Run").ReferenceCount); + Assert.Equal(1, Assert.Single(completePartialCallers, result => result.CallerName == "Noise").ReferenceCount); + Assert.Equal(1, Assert.Single(completePartialCallers, result => result.CallerName == "Complex").ReferenceCount); Assert.Equal(3, Assert.Single(completeCallers, result => result.CallerName == "Run").ReferenceCount); Assert.Equal(1, Assert.Single(completeCallers, result => result.CallerName == "Noise").ReferenceCount); + Assert.Equal(1, Assert.Single(completeCallers, result => result.CallerName == "Complex").ReferenceCount); Assert.Equal(1, _reader.CountCallers("GetString", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"])); Assert.Equal( - 2, + 3, _reader.CountCallers( "GetString", lang: "csharp", @@ -3293,6 +3326,17 @@ public int Parse(string value) exact: true, pathPatterns: ["src/*common_member_graph_fixture*"], includeQualifiedCommonCalls: true)); + Assert.Empty(_reader.GetCallees( + "Complex", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"])); + Assert.Single(_reader.GetCallees( + "Complex", + lang: "csharp", + exact: true, + pathPatterns: ["src/*common_member_graph_fixture*"], + includeQualifiedCommonCalls: true)); var defaultParseCallee = Assert.Single( _reader.GetCallees("Parse", lang: "csharp", exact: true, pathPatterns: ["src/*common_member_graph_fixture*"])); var completeParseCallee = Assert.Single(_reader.GetCallees( @@ -3323,7 +3367,7 @@ public int Parse(string value) null, false), result => result.Symbol.Name == "GetString"); - Assert.Equal(1, hotspot.ReferenceCount); + Assert.Equal(2, hotspot.ReferenceCount); } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index 8a5c0860e..d9958b29d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -4586,6 +4586,84 @@ FROM symbol_references } } + [Fact] + public void Run_FullScan_RefreshesHotspotWhenAddedTargetResolvesSkippedCommonCall_Issue4867() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText( + Path.Combine(projectRoot, "Caller.cs"), + """ + public class Caller + { + public void Run(LocalApi api) => api.GetString(); + } + """); + + Assert.Equal( + CommandExitCodes.Success, + IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions)); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var initial = OpenNonPoolingConnection(dbPath)) + { + initial.Open(); + using var command = initial.CreateCommand(); + command.CommandText = """ + SELECT resolution_state + FROM symbol_references + WHERE symbol_name = 'GetString' + """; + Assert.Equal("unresolved", command.ExecuteScalar() as string); + command.CommandText = """ + SELECT COALESCE(SUM(reference_count), 0) + FROM hotspot_reference_counts + WHERE lang = 'csharp' + AND raw_symbol_name = 'GetString' + """; + Assert.Equal(0L, command.ExecuteScalar()); + } + + File.WriteAllText( + Path.Combine(projectRoot, "Target.cs"), + """ + public class LocalApi + { + public string GetString() => ""; + } + """); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("files_skipped").GetInt32()); + + using var verify = OpenNonPoolingConnection(dbPath); + verify.Open(); + using var verifyCommand = verify.CreateCommand(); + verifyCommand.CommandText = """ + SELECT resolution_state + FROM symbol_references + WHERE symbol_name = 'GetString' + """; + Assert.Equal("resolved", verifyCommand.ExecuteScalar() as string); + verifyCommand.CommandText = """ + SELECT COALESCE(SUM(reference_count), 0) + FROM hotspot_reference_counts + WHERE lang = 'csharp' + AND raw_symbol_name = 'GetString' + """; + Assert.Equal(1L, verifyCommand.ExecuteScalar()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_FullScan_StampsRefreshedDynamicGraphContractWhenFoldContractRemainsStale_Issue4746() {