diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 00e1061af..5ec076f76 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -162,6 +162,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Reference-identity write coverage uses per-column SQLite audit triggers around one self-resolving reference. It requires a stable rerun to write no source, resolution, self, or mutual rows; corrupts all four phases; aborts target resolution to prove the earlier source update rolls back; then requires exactly one repair write per phase and another stable zero-write rerun. Reusable-stat snapshot semantics keep one valid row beside two rows from the same stale extractor language plus NULL, text, integer, and invalid-timestamp stat storage, so malformed-row filtering and cached stale-language rejection remain one contract. Graph-kind regressions keep default canonical `call` / `instantiate` / `subscribe` output, raw-kind output, and explicit non-default kind access in the same seeded fixture so filtering and projection cannot drift apart. + Caller-ranking coverage keeps the large count-over-path-category case, primary-recipe ties, multi-path filtering, test exclusion, and offset page reconstruction in one seeded `DbReaderTests` fixture. CLI human/JSON and MCP pagination assertions reuse compact two- or three-caller fixtures and require the structured ranking recipe to match the ordering applied before pagination (#4881). Query-only regression fixtures run representative `status`, `search`, and `files` commands against a checkpointed WAL database copied without sidecars and assert that the database, `-wal`, and `-shm` artifact set plus persistent pragmas remain unchanged. Separate fixtures prove that committed hot-WAL content, empty-to-hot transitions, checkpoint resets, cross-database dependency targets, and long-lived LSP refreshes use cleaned-up generation-validated private snapshots without touching source sidecars. Cross-database coverage also pins disposal ordering and snapshot-directory cleanup, while injected persistent copy failures pin the non-retrying `query_only_snapshot_copy_failed` taxonomy; interrupted-batch readiness demotion is covered separately through explicit `Repair` intent. `DatabasePermissionPolicyTests.cs` injects a file-mode provider so unsupported and access-denied Unix mode operations remain deterministic and cross-platform while proving both best-effort diagnostics and strict remediation failures. `DbSchemaConstraintTests.cs` also locks schema constraints to `SymbolKindCatalog` and required file foreign keys so DB readiness checks fail when code enums and SQLite CHECK clauses drift. @@ -1095,6 +1096,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" reference identity writeのcoverageは、self-resolving reference 1件に列別SQLite audit triggerを設定します。安定rerunでsource・resolution・self・mutual rowのwriteが0、全4 phaseをcorruptした後にtarget resolutionを中断して先行source updateもrollback、復旧時はphaseごとにちょうど1 write、その後の安定rerunは再び0 writeであることを必須とします。 再利用 stat snapshot の意味論は、有効な1行、同じ stale extractor 言語の2行、NULL / text / integer / 不正 timestamp の stat storage を同居させ、malformed row の除外と stale-language 判定の false cache を1つの契約として固定します。 graph kind の回帰では、既定 canonical `call` / `instantiate` / `subscribe` 出力、raw-kind 出力、明示的な非既定 kind access を同じ seed 済み fixture にまとめ、filtering と projection のずれを検出します。 + caller ranking の coverage では、count と path category に大差がある場合、primary recipe の同順位、複数 path filter、test 除外、offset page の再構成を1つの seed 済み `DbReaderTests` fixture にまとめます。CLI の human / JSON と MCP pagination の assertion は2~3 caller の小さな fixture を再利用し、structured ranking recipe が pagination 前に適用した順序と一致することを必須とします (#4881)。 query-only の回帰 fixture では sidecar なしで copy した checkpoint 済み WAL database に対して代表的な `status`、`search`、`files` command を実行し、database、`-wal`、`-shm` の artifact set と永続 pragma が変化しないことを検証します。別の fixture では committed hot-WAL content、empty-to-hot transition、checkpoint reset、cross-database dependency target、長時間 LSP refresh が、source sidecar に触れず cleanup される generation 検証済み private snapshot を使うことを固定します。cross-database coverage は dispose 順序と snapshot directory cleanup も固定し、注入した永続的 copy failure は retry されない `query_only_snapshot_copy_failed` taxonomy を固定します。interrupted batch の readiness demotion は明示的な `Repair` intent の別テストで固定します。 `DatabasePermissionPolicyTests.cs` は file-mode provider を注入し、unsupported / access-denied な Unix mode 操作を deterministic かつ cross-platform に保ちながら、best-effort diagnostic と strict remediation failure の両方を検証します。 `DbSchemaConstraintTests.cs` は DB readiness check が code enum と SQLite CHECK 句の drift を検出できるよう、schema constraint と `SymbolKindCatalog`、必須 file foreign key の同期も固定します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index db93b751a..ae5c23bae 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1743,7 +1743,7 @@ By default, `callers` and `callees` return only executable call, construction, a 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`. +`callers` and `callees` rank results by an explicit primary recipe. `weighted` (the default) orders by `reference_weight_score DESC` and then `reference_count DESC`, where `instantiate=3.0`, direct `call=1.0`, and event `subscribe=0.1`. `--rank-by count` makes raw `reference_count DESC` the true primary key, and `--rank-by kind` uses `instantiate`, `call`, `generic_type_argument`, `subscribe`, then other kinds before `reference_count DESC`. Only rows tied on that primary recipe use, in order, exact-case match, exact-name match, path category (`production`, `test`, `documentation`), path, first line, first column, language, container kind, container name, symbol name, and reference kind. Test or documentation paths therefore never override the requested primary rank; `--exclude-tests` removes test rows instead of merely demoting them. CLI JSON exposes this contract as `query_context.rank_by` and `query_context.ranking_recipe`, while MCP exposes `rankBy` and `rankingRecipe`; each recipe contains the complete machine-readable `precedence` array used before pagination. JSON rows also keep raw `reference_count`, `reference_kind_counts`, and `reference_weight_score` (`referenceCount`, `referenceKindCounts`, and `referenceWeightScore` in MCP). 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. @@ -2057,7 +2057,7 @@ same source location. | `--prefix` | `search` | Opt into FTS5 prefix-phrase expansion for every token in the query. Without this flag the literal-safe path quotes each token as a strict FTS5 phrase, so a bare `search 計算` only matches the token `計算` and not `計算する` (unicode61 keeps adjacent CJK codepoints as one token). Appending `*` to a single token (`search 計算*`) opts in for that token only; `--prefix` opts in for the whole query. Cannot be combined with `--exact` / `--exact-substring` / `--token-boundary` (those bypass FTS5 entirely). | | `--exact-name` | `symbols`, `definition`, `references`, `callers`, `callees`, `inspect` | Preferred explicit name for symbol-name exactness: NFKC + Unicode CaseFold exact equality (`Ä` / `ä`, `Run` / `Run`, ligatures, sharp-S, and Greek final sigma collapse). Unicode CaseFold remains locale-invariant, so Turkish dotted `İ` is still distinct from plain `i`. For C#, pass the canonical extracted name (`operator +`, `operator checked +`, `explicit operator Money`, `implicit operator decimal`, `Item`) rather than source keywords like `this` / `explicit`. Falls back to ASCII `COLLATE NOCASE` while the DB still contains stale fold metadata; prefer `cdidx backfill-fold`, or use a plain `cdidx index .` if it rewrites or purges every stale row, otherwise `--rebuild`. `status --json` exposes `fold_ready` and `csharp_symbol_name_ready` so AI clients can tell which path is active. When a read-only legacy DB is missing the fallback exact-match indexes, human-readable output warns and CLI JSON / MCP `structuredContent` expose degraded-state metadata. | | `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `inspect`, `outline`, `hotspots`, `unused`, `validate` | Filter by kind (case-insensitive; `--kind FUNCTION` is treated as `--kind function`). `outline` also accepts comma-separated symbol kinds, such as `--kind function,class`. `definition` / `symbols` / `inspect` / `outline` / `hotspots` / `unused` use symbol kinds (`function`, `lambda`, `async_function`, `generator`, `async_generator`, `test.method`, `class`, `struct`, `interface`, `protocol`, `enum`, `property`, `event`, `delegate`, `namespace`, `import`); `references` accepts all indexed reference kinds (`call`, `instantiate`, `subscribe`, `attribute`, `annotation`, `type_reference`, `type_tag`); `callers` / `callees` accept only the call-graph kinds (`call`, `instantiate`, `subscribe`) and reject non-call-graph kinds (`--kind attribute` / `--kind annotation` / `--kind type_reference` / `--kind type_tag`) with a usage error — metadata rows are attributed to the enclosing body-range symbol rather than the annotated target, `type_reference` rows are compile-time type-position edges (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`), and `type_tag` rows describe JavaScript/TypeScript discriminant narrowing rather than runtime calls, so `callers` / `callees` cannot answer these kinds correctly; use `references --kind attribute` / `references --kind annotation` / `references --kind type_reference` / `references --kind type_tag` instead. `inspect` filters the definition candidates and primary file context while keeping graph evidence keyed to the queried symbol name. `references` defaults to every indexed reference kind so metadata usages remain visible, while `callers` / `callees` / `hotspots` / `impact` default to the call-graph kinds only (`call`, `instantiate`, `subscribe`) and exclude metadata edges (`attribute`, `annotation`, `type_reference`, `type_tag`). Identical constructor `call` + `instantiate` rows at one physical site still collapse; `validate` uses issue kinds such as `bom` | -| `--rank-by ` | `callers`, `callees` | Choose the caller/callee ranking model. `weighted` is the default and scores `instantiate=3.0`, `call=1.0`, `subscribe=0.1`; `count` sorts by raw `reference_count`; `kind` groups by reference kind first, then count. | +| `--rank-by ` | `callers`, `callees` | Choose the primary caller/callee ranking recipe. `weighted` orders by weighted score then count; `count` orders by raw `reference_count`; `kind` orders by kind priority then count. Only ties use exact-case/name relevance, production/test/documentation path category, and stable path/location/name fields. JSON/MCP ranking recipes publish the complete precedence applied before pagination. | | `--body` | `definition`, `references`, `callers`, `callees`, `impact`, `inspect` | Include reconstructed body content or capped graph-location excerpts | | `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | Return only counts. `search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` / `hotspots` ignore `--limit` and return authoritative totals; only `impact` still reports the visible page count and may truncate with `--limit` (with `--json`: a single count object; commands that expose file counts add `files`) | | `--group-partials` | `definition`, `symbols`, `inspect` | Collapse actual C# `partial` declarations with the same qualified family identity into logical families. Off by default; unrelated non-partial types remain physical rows. Each grouped result keeps a stable representative and exposes its physical declaration count through `definition_sites`; grouped count JSON includes `logical_count`, `physical_count`, and `physical_file_count`. Audit sorts use per-family maximum rank metrics. `inspect` supports this only for symbol queries, not positional paths or `--path` / `--line` coordinates. | @@ -4977,7 +4977,7 @@ cdidx callees AddToGitExclude --exclude-tests 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 側で再ランキングできます。 +`callers` と `callees` は、明示的な primary recipe で結果を並べます。既定の `weighted` は `reference_weight_score DESC`、次に `reference_count DESC` の順で、`instantiate=3.0`、直接 `call=1.0`、event `subscribe=0.1` として計算します。`--rank-by count` では生の `reference_count DESC` が真の primary key になり、`--rank-by kind` では `instantiate`、`call`、`generic_type_argument`、`subscribe`、その他の kind の順を優先してから `reference_count DESC` を適用します。この primary recipe で同順位の場合だけ、完全な大小文字一致、完全な名前一致、path category(`production`、`test`、`documentation`)、path、最初の行、最初の列、language、container kind、container name、symbol name、reference kind の順で tie-break します。そのため test / documentation path が指定した primary rank を上書きすることはなく、`--exclude-tests` は test 行を単に降格するのではなく除外します。CLI JSON はこの契約を `query_context.rank_by` と `query_context.ranking_recipe`、MCP は `rankBy` と `rankingRecipe` で公開し、各 recipe の machine-readable な `precedence` 配列には pagination 前に適用した全順序が入ります。JSON row は生の `reference_count`、`reference_kind_counts`、`reference_weight_score` も保持し、MCP では `referenceCount`、`referenceKindCounts`、`referenceWeightScore` として返します。 集約された `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/4881.fixed.md b/changelog.d/unreleased/4881.fixed.md new file mode 100644 index 000000000..b38477b44 --- /dev/null +++ b/changelog.d/unreleased/4881.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4881 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs + - USER_GUIDE.md +--- + +## English + +- **Caller and callee ranking now honors the selected primary recipe (#4881)** — `--rank-by count` uses raw reference count before source/test/documentation path preferences, all ranking modes use deterministic tie-breakers before pagination, and CLI JSON plus MCP responses publish the complete applied ranking recipe. + +## 日本語 + +- **caller / callee ranking が選択した primary recipe を優先するようになりました (#4881)** — `--rank-by count` は source / test / documentation の path 優先度より先に生の reference count を適用し、すべての ranking mode は pagination 前に決定的な tie-breaker を使います。CLI JSON と MCP response は、適用した ranking recipe 全体も公開します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 0626fa539..bdfb1705e 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -477,7 +477,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--actionable", Description = "Unused: preset for private medium-confidence cleanup candidates", PrimaryCommands = Set(UnusedFilterCommands) }, new() { Name = "--all", Description = "goto: return all matching LSP locations; find: search all indexed files instead of requiring --path; unused: include low-confidence contract-domain candidates suppressed by default", PrimaryCommands = Set(AllResultCommands) }, new() { Name = "--line-scan-limit", ValuePlaceholder = "", Description = "Find: override the --all indexed-line scan cap", PrimaryCommands = Set("find") }, - new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by weighted structural score, raw count, or kind bucket", PrimaryCommands = Set(RankByCommands) }, + new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by the selected primary recipe, then exact-name relevance, production/test/docs path category, and stable location/name tie-breakers", 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") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index b4f60d0f8..2fe266dcb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -259,9 +259,17 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions foreach (var r in results) { if (exact) - WriteGraphJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CallerResult, exactSignal, jsonOptions, extraFields: payload => AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal)); + WriteGraphJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CallerResult, exactSignal, jsonOptions, extraFields: payload => + { + AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); + AddReferenceRankingQueryContextJson(payload, options, jsonOptions); + }); else - WriteJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CallerResult, jsonOptions, extraFields: payload => AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal)); + WriteJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CallerResult, jsonOptions, extraFields: payload => + { + AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); + AddReferenceRankingQueryContextJson(payload, options, jsonOptions); + }); } } else @@ -406,9 +414,17 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions foreach (var r in results) { if (exact) - WriteGraphJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CalleeResult, exactSignal, jsonOptions, extraFields: payload => AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal)); + WriteGraphJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CalleeResult, exactSignal, jsonOptions, extraFields: payload => + { + AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); + AddReferenceRankingQueryContextJson(payload, options, jsonOptions); + }); else - WriteJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CalleeResult, jsonOptions, extraFields: payload => AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal)); + WriteJsonResult(r, CliJsonSerializerContextFactory.Create(jsonOptions).CalleeResult, jsonOptions, extraFields: payload => + { + AddGraphContractJsonFields(payload, reader, jsonOptions, sqlGraphSignal, hdlGraphSignal); + AddReferenceRankingQueryContextJson(payload, options, jsonOptions); + }); } } else @@ -447,6 +463,7 @@ private static bool TryParseGraphCommandOptions( } options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + options.ReferenceRankingActive = command is "callers" or "callees"; if (TryWriteUnsupportedOptionError( command, cmdArgs, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.GraphOutput.cs b/src/CodeIndex/Cli/QueryCommandRunner.GraphOutput.cs index 77a0fa514..f0ee5b93c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.GraphOutput.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.GraphOutput.cs @@ -214,7 +214,7 @@ private static void WriteDegradedGraphZeroResult(DbReader reader, string results { var payload = BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: resultsKey, graphTableAvailable: false, degraded: true, exactSignal: exactSignal, queryOptions: queryOptions, extraFields: extraFields); payload["note"] = "symbol_references table is missing in this index (legacy or read-only DB). Zero result is degraded, not authoritative."; - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } else { @@ -252,7 +252,7 @@ private static void WriteGraphCountResult(DbReader reader, int count, int files, extraFields?.Invoke(payload); AddCountAuthorityJsonFields(payload); AddActiveSqliteDiagnostics(payload); - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } private static void WriteGraphZeroJsonResult(DbReader reader, string resultsKey, JsonSerializerOptions jsonOptions, bool graphAvailable, @@ -271,7 +271,7 @@ private static void WriteGraphZeroJsonResult(DbReader reader, string resultsKey, payload["exact_zero_hint"] = JsonSerializer.SerializeToNode(exactZeroHint, CliJsonSerializerContextFactory.Create(jsonOptions).ExactZeroHintResult); extraFields?.Invoke(payload); AddActiveSqliteDiagnostics(payload); - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } private static void WriteGraphJsonResult(T result, JsonTypeInfo jsonTypeInfo, ExactQuerySignal exactSignal, JsonSerializerOptions jsonOptions, GraphSupportOverride? graphSupportOverride = null, Action? extraFields = null) @@ -281,7 +281,7 @@ private static void WriteGraphJsonResult(T result, JsonTypeInfo jsonTypeIn AddGraphSupportOverrideFields(payload, graphSupportOverride); extraFields?.Invoke(payload); AddActiveSqliteDiagnostics(payload); - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } private static void WriteJsonResult(T result, JsonTypeInfo jsonTypeInfo, JsonSerializerOptions jsonOptions, Action? extraFields = null) @@ -289,7 +289,7 @@ private static void WriteJsonResult(T result, JsonTypeInfo jsonTypeInfo, J var payload = JsonSerializer.SerializeToNode(result, jsonTypeInfo)!.AsObject(); extraFields?.Invoke(payload); AddActiveSqliteDiagnostics(payload); - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } private static void WriteJsonResultWithExactSignal(T result, JsonTypeInfo jsonTypeInfo, ExactQuerySignal exactSignal, JsonSerializerOptions jsonOptions) @@ -297,7 +297,7 @@ private static void WriteJsonResultWithExactSignal(T result, JsonTypeInfo var payload = JsonSerializer.SerializeToNode(result, jsonTypeInfo)!.AsObject(); AddExactJsonFields(payload, exactSignal); AddActiveSqliteDiagnostics(payload); - Console.WriteLine(payload.ToJsonString(jsonOptions)); + Console.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); } private static void AddExactGraphJsonFields(JsonObject payload, ExactQuerySignal exactSignal) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index c1852662b..013babefb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -157,7 +157,7 @@ private static IEnumerable BuildQueryContextParts(QueryCommandOptions op yield return $"min-confidence: {options.MinUnusedConfidence}"; if (options.UnusedActionable) yield return "actionable: true"; - if (options.RankMode != ReferenceRankMode.Weighted) + if (options.ReferenceRankingActive || options.RankMode != ReferenceRankMode.Weighted) yield return $"rank-by: {FormatReferenceRankMode(options.RankMode)}"; if (options.ExcludeTests) yield return "exclude-tests: true"; @@ -233,7 +233,12 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso ?? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"unused:{options.UnusedCursorOffset.Value}"); query["offset"] = options.UnusedCursorOffset.Value; } - if (options.RankMode != ReferenceRankMode.Weighted) + if (options.ReferenceRankingActive) + { + query["rank_by"] = FormatReferenceRankMode(options.RankMode); + query["ranking_recipe"] = BuildReferenceRankingRecipeJson(options.RankMode); + } + else if (options.RankMode != ReferenceRankMode.Weighted) query["rank_by"] = FormatReferenceRankMode(options.RankMode); if (options.SymbolSortMode != SymbolSortMode.Name) query["sort"] = options.SymbolSortMode.ToString().ToLowerInvariant(); @@ -313,6 +318,14 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso return query; } + private static void AddReferenceRankingQueryContextJson( + JsonObject payload, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions) + { + payload["query_context"] = BuildQueryContextJson(options, jsonOptions); + } + private static JsonArray BuildSearchRowSelectorContextJson(QueryCommandOptions options) { var selectors = new JsonArray(); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 85b467fb0..e1ba476fa 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -64,6 +64,19 @@ public static partial class QueryCommandRunner ReferenceRankMode.Kind => "kind", _ => "weighted", }; + + internal static JsonObject BuildReferenceRankingRecipeJson(ReferenceRankMode mode) + { + var precedence = new JsonArray(); + foreach (var dimension in ReferenceRankRecipes.Get(mode)) + precedence.Add(ReferenceRankRecipes.Format(dimension)); + + return new JsonObject + { + ["mode"] = FormatReferenceRankMode(mode), + ["precedence"] = precedence, + }; + } } public sealed class QueryCommandOptions @@ -180,6 +193,7 @@ public sealed class QueryCommandOptions public bool StatusConfig { get; init; } public bool? RedactPaths { get; init; } public ReferenceRankMode RankMode { get; init; } = ReferenceRankMode.Weighted; + internal bool ReferenceRankingActive { get; set; } public SymbolSortMode SymbolSortMode { get; init; } = SymbolSortMode.Name; public string? SortValue { get; init; } public bool SortExplicit { get; init; } diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 0f3ff62da..e11482fa1 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -218,7 +218,7 @@ FROM symbol_reference_candidates AS identity_candidate MAX(r.is_mutual_recursion) AS is_mutual_recursion FROM logical_references r GROUP BY path, lang, container_kind, container_name, symbol_name"; - sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.symbol_name = @rawQuery THEN 0 ELSE 1 END, {GetPathBucketOrderSql("r.path")}, CASE WHEN lower(r.symbol_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, r.path, first_line, first_column, r.lang, r.container_kind, r.container_name, r.symbol_name LIMIT @limit OFFSET @offset"; + sql += $" ORDER BY {BuildReferenceRankOrderSql(rankMode, "r.symbol_name")} LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; string callersQueryParam; @@ -242,8 +242,7 @@ FROM logical_references r : cssScssVariableAlias; SqliteCommandPolicy.Add(cmd, "@queryCssScssVariableAlias", aliasParam); } - SqliteCommandPolicy.Add(cmd, "@preferExactCase", exact ? 1 : 0); - SqliteCommandPolicy.Add(cmd, "@rawQuery", exact ? query : string.Empty); + SqliteCommandPolicy.Add(cmd, "@rawQuery", query); SqliteCommandPolicy.Add(cmd, "@rankingQuery", query.Trim()); if (RequiresReferenceKindParameter(referenceKind)) SqliteCommandPolicy.Add(cmd, "@referenceKind", referenceKind); @@ -696,7 +695,7 @@ FROM logical_references SUM(r.weighted_score) AS weighted_score FROM ranked_call_sites r GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; - sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.container_name = @rawQuery THEN 0 ELSE 1 END, {GetPathBucketOrderSql("r.path")}, CASE WHEN lower(r.container_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, r.path, first_line, r.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind LIMIT @limit OFFSET @offset"; + sql += $" ORDER BY {BuildReferenceRankOrderSql(rankMode, "r.container_name")} LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; string calleesQueryParam; @@ -722,8 +721,7 @@ FROM ranked_call_sites r : cssScssVariableAlias; SqliteCommandPolicy.Add(cmd, "@queryCssScssVariableAlias", aliasParam); } - SqliteCommandPolicy.Add(cmd, "@preferExactCase", exact ? 1 : 0); - SqliteCommandPolicy.Add(cmd, "@rawQuery", exact ? query : string.Empty); + SqliteCommandPolicy.Add(cmd, "@rawQuery", query); SqliteCommandPolicy.Add(cmd, "@rankingQuery", query.Trim()); AddQualifiedGraphQueryParameters(cmd, query, allowQualifiedLeafFallback); if (RequiresReferenceKindParameter(referenceKind)) @@ -1012,12 +1010,32 @@ private static string ReferenceWeightedScoreSql(string columnSql) => $@" ELSE 0.0 END)"; - private static string BuildReferenceRankOrderSql(ReferenceRankMode rankMode) => rankMode switch - { - ReferenceRankMode.Count => "reference_count DESC", - ReferenceRankMode.Kind => "CASE reference_kind WHEN 'instantiate' THEN 0 WHEN 'call' THEN 1 WHEN 'generic_type_argument' THEN 2 WHEN 'subscribe' THEN 3 ELSE 4 END, reference_count DESC", - _ => "weighted_score DESC, reference_count DESC", - }; + private static string BuildReferenceRankOrderSql( + ReferenceRankMode rankMode, + string queriedNameSql) + => string.Join( + ", ", + ReferenceRankRecipes.Get(rankMode).Select(dimension => dimension switch + { + ReferenceRankDimension.ReferenceWeightScoreDescending => "weighted_score DESC", + ReferenceRankDimension.ReferenceCountDescending => "reference_count DESC", + ReferenceRankDimension.ReferenceKindPriorityAscending => + "CASE reference_kind WHEN 'instantiate' THEN 0 WHEN 'call' THEN 1 WHEN 'generic_type_argument' THEN 2 WHEN 'subscribe' THEN 3 ELSE 4 END", + ReferenceRankDimension.ExactCaseMatchDescending => + $"CASE WHEN {queriedNameSql} = @rawQuery THEN 0 ELSE 1 END", + ReferenceRankDimension.ExactNameMatchDescending => + $"CASE WHEN lower({queriedNameSql}) = lower(@rankingQuery) THEN 0 ELSE 1 END", + ReferenceRankDimension.PathCategoryAscending => GetPathBucketOrderSql("r.path"), + ReferenceRankDimension.PathAscending => "r.path", + ReferenceRankDimension.FirstLineAscending => "first_line", + ReferenceRankDimension.FirstColumnAscending => "first_column", + ReferenceRankDimension.LanguageAscending => "r.lang", + ReferenceRankDimension.ContainerKindAscending => "r.container_kind", + ReferenceRankDimension.ContainerNameAscending => "r.container_name", + ReferenceRankDimension.SymbolNameAscending => "r.symbol_name", + ReferenceRankDimension.ReferenceKindAscending => "reference_kind", + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, null), + })); private static IReadOnlyDictionary ParseReferenceKindCounts(string? aggregate, string primaryKind, int fallbackCount) { diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 5abeb7424..8932ef3a0 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -127,7 +127,7 @@ private static JsonArray CreateToolCatalog() ["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" }, + ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Primary ranking recipe: weighted score then count (default; instantiate=3.0, call=1.0, subscribe=0.1), raw count, or kind priority then count. Only ties use exact-case/name relevance, production before test before docs path category, then stable path/location/name fields. Responses expose the complete applied precedence in rankingRecipe.", ["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 }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, @@ -155,7 +155,7 @@ private static JsonArray CreateToolCatalog() ["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" }, + ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Primary ranking recipe: weighted score then count (default; instantiate=3.0, call=1.0, subscribe=0.1), raw count, or kind priority then count. Only ties use exact-case/name relevance, production before test before docs path category, then stable path/location/name fields. Responses expose the complete applied precedence in rankingRecipe.", ["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 }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index 269c610e3..3ac37e3e5 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -179,6 +179,8 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + countOnlyPayload["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode); + countOnlyPayload["rankingRecipe"] = QueryCommandRunner.BuildReferenceRankingRecipeJson(rankMode); AddReferenceGraphCompletenessSignal( countOnlyPayload, reader, @@ -218,6 +220,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), + ["rankingRecipe"] = QueryCommandRunner.BuildReferenceRankingRecipeJson(rankMode), ["graph_language"] = graphSupport.GraphLanguage, ["graph_supported"] = graphSupport.GraphSupported, ["graph_support_reason"] = graphSupport.GraphSupportReason, @@ -305,6 +308,8 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + countOnlyPayload["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode); + countOnlyPayload["rankingRecipe"] = QueryCommandRunner.BuildReferenceRankingRecipeJson(rankMode); AddReferenceGraphCompletenessSignal( countOnlyPayload, reader, @@ -344,6 +349,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), + ["rankingRecipe"] = QueryCommandRunner.BuildReferenceRankingRecipeJson(rankMode), ["graph_language"] = graphSupport.GraphLanguage, ["graph_supported"] = graphSupport.GraphSupported, ["graph_support_reason"] = graphSupport.GraphSupportReason, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index d9d44619f..93008960b 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -212,6 +212,93 @@ public enum ReferenceRankMode Kind, } +internal enum ReferenceRankDimension +{ + ReferenceWeightScoreDescending, + ReferenceCountDescending, + ReferenceKindPriorityAscending, + ExactCaseMatchDescending, + ExactNameMatchDescending, + PathCategoryAscending, + PathAscending, + FirstLineAscending, + FirstColumnAscending, + LanguageAscending, + ContainerKindAscending, + ContainerNameAscending, + SymbolNameAscending, + ReferenceKindAscending, +} + +internal static class ReferenceRankRecipes +{ + private static readonly IReadOnlyList StableTieBreakers = + Array.AsReadOnly( + [ + ReferenceRankDimension.ExactCaseMatchDescending, + ReferenceRankDimension.ExactNameMatchDescending, + ReferenceRankDimension.PathCategoryAscending, + ReferenceRankDimension.PathAscending, + ReferenceRankDimension.FirstLineAscending, + ReferenceRankDimension.FirstColumnAscending, + ReferenceRankDimension.LanguageAscending, + ReferenceRankDimension.ContainerKindAscending, + ReferenceRankDimension.ContainerNameAscending, + ReferenceRankDimension.SymbolNameAscending, + ReferenceRankDimension.ReferenceKindAscending, + ]); + + private static readonly IReadOnlyList Weighted = + Build( + ReferenceRankDimension.ReferenceWeightScoreDescending, + ReferenceRankDimension.ReferenceCountDescending); + + private static readonly IReadOnlyList Count = + Build(ReferenceRankDimension.ReferenceCountDescending); + + private static readonly IReadOnlyList Kind = + Build( + ReferenceRankDimension.ReferenceKindPriorityAscending, + ReferenceRankDimension.ReferenceCountDescending); + + internal static IReadOnlyList Get(ReferenceRankMode mode) => mode switch + { + ReferenceRankMode.Count => Count, + ReferenceRankMode.Kind => Kind, + _ => Weighted, + }; + + internal static string Format(ReferenceRankDimension dimension) => dimension switch + { + ReferenceRankDimension.ReferenceWeightScoreDescending => "reference_weight_score_desc", + ReferenceRankDimension.ReferenceCountDescending => "reference_count_desc", + ReferenceRankDimension.ReferenceKindPriorityAscending => "reference_kind_priority_asc", + ReferenceRankDimension.ExactCaseMatchDescending => "exact_case_match_desc", + ReferenceRankDimension.ExactNameMatchDescending => "exact_name_match_desc", + ReferenceRankDimension.PathCategoryAscending => "path_category_asc", + ReferenceRankDimension.PathAscending => "path_asc", + ReferenceRankDimension.FirstLineAscending => "first_line_asc", + ReferenceRankDimension.FirstColumnAscending => "first_column_asc", + ReferenceRankDimension.LanguageAscending => "language_asc", + ReferenceRankDimension.ContainerKindAscending => "container_kind_asc", + ReferenceRankDimension.ContainerNameAscending => "container_name_asc", + ReferenceRankDimension.SymbolNameAscending => "symbol_name_asc", + ReferenceRankDimension.ReferenceKindAscending => "reference_kind_asc", + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, null), + }; + + private static IReadOnlyList Build( + params ReferenceRankDimension[] primaryDimensions) + { + var recipe = new ReferenceRankDimension[ + primaryDimensions.Length + StableTieBreakers.Count]; + primaryDimensions.CopyTo(recipe, 0); + for (var index = 0; index < StableTieBreakers.Count; index++) + recipe[primaryDimensions.Length + index] = StableTieBreakers[index]; + return Array.AsReadOnly(recipe); + } +} + public enum SymbolSortMode { Name, diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 183b5b984..5a5501040 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -1015,6 +1015,82 @@ public void GetCallers_DefaultWeightedRankingPrioritizesInstantiateOverNoisySubs Assert.Equal(50, countRanked[0].ReferenceKindCounts["subscribe"]); } + [Fact] + public void GetCallers_CountRankingIsPrimaryAndTieBreakersKeepPagesStable_Issue4881() + { + const string target = "RankedTarget"; + InsertManualReferences("tests/HighVolumeTests.cs", "HighVolumeTests", target, "call", 284); + InsertManualReferences("src/ProductionCaller.cs", "ProductionCaller", target, "call", 1); + InsertManualReferences("tests/TiedTests.cs", "TiedTests", target, "call", 1); + InsertManualReferences("docs/DocumentedSample.cs", "DocumentedSample", target, "call", 1); + + var all = _reader.GetCallers( + target, + lang: "csharp", + exact: true, + rankMode: ReferenceRankMode.Count); + + Assert.Equal( + [ + "tests/HighVolumeTests.cs", + "src/ProductionCaller.cs", + "tests/TiedTests.cs", + "docs/DocumentedSample.cs", + ], + all.Select(result => result.Path)); + Assert.Equal(284, all[0].ReferenceCount); + + var firstPage = _reader.GetCallers( + target, + limit: 2, + lang: "csharp", + exact: true, + rankMode: ReferenceRankMode.Count); + var secondPage = _reader.GetCallers( + target, + limit: 2, + lang: "csharp", + exact: true, + rankMode: ReferenceRankMode.Count, + offset: 2); + Assert.Equal( + all.Select(result => (result.Path, result.CallerName)), + firstPage.Concat(secondPage).Select(result => (result.Path, result.CallerName))); + + var multiPath = _reader.GetCallers( + target, + lang: "csharp", + pathPatterns: ["src/**", "tests/**"], + exact: true, + rankMode: ReferenceRankMode.Count); + Assert.Equal( + ["tests/HighVolumeTests.cs", "src/ProductionCaller.cs", "tests/TiedTests.cs"], + multiPath.Select(result => result.Path)); + + var withoutTests = _reader.GetCallers( + target, + lang: "csharp", + excludeTests: true, + exact: true, + rankMode: ReferenceRankMode.Count); + Assert.Equal( + ["src/ProductionCaller.cs", "docs/DocumentedSample.cs"], + withoutTests.Select(result => result.Path)); + + const string caseQuery = "CaseRankedTarget"; + InsertManualReferences("src/ZExactCase.cs", "ExactCaseCaller", caseQuery, "call", 1); + InsertManualReferences("src/AFoldedCase.cs", "FoldedCaseCaller", caseQuery.ToLowerInvariant(), "call", 1); + + var fuzzyCaseTie = _reader.GetCallers( + caseQuery, + lang: "csharp", + exact: false, + rankMode: ReferenceRankMode.Count); + + Assert.Equal(caseQuery, fuzzyCaseTie[0].CalleeName); + Assert.Equal("src/ZExactCase.cs", fuzzyCaseTie[0].Path); + } + [Theory] [InlineData("src/top-level.js", "javascript")] [InlineData("src/top-level.ts", "typescript")] diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index cc3c5af38..e47e7f4fa 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -470,7 +470,7 @@ void Target() { } """); var firstRequest = JsonNode.Parse( - """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","limit":2}}}""")!; + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","rankBy":"count","limit":2}}}""")!; var firstResponse = _server.HandleMessage(firstRequest)!; var first = firstResponse["result"]!["structuredContent"]!; @@ -478,12 +478,21 @@ void Target() { } Assert.True(first["truncated"]!.GetValue()); Assert.True(first["more_available"]!.GetValue()); Assert.Equal(2, first["next_offset"]!.GetValue()); + Assert.Equal("count", first["rankBy"]!.GetValue()); + var firstRecipe = first["rankingRecipe"]!; + Assert.Equal("count", firstRecipe["mode"]!.GetValue()); + Assert.Equal( + "reference_count_desc", + firstRecipe["precedence"]![0]!.GetValue()); + Assert.Contains( + "path_category_asc", + firstRecipe["precedence"]!.AsArray().Select(item => item!.GetValue())); var firstNames = first["results"]!.AsArray() .Select(row => row!["callerName"]!.GetValue()) .ToArray(); var secondRequest = JsonNode.Parse( - """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","limit":2,"offset":2}}}""")!; + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","rankBy":"count","limit":2,"offset":2}}}""")!; var secondResponse = _server.HandleMessage(secondRequest)!; var second = secondResponse["result"]!["structuredContent"]!; @@ -491,6 +500,7 @@ void Target() { } Assert.False(second["truncated"]!.GetValue()); Assert.False(second["more_available"]!.GetValue()); Assert.Null(second["next_offset"]); + Assert.True(JsonNode.DeepEquals(firstRecipe, second["rankingRecipe"])); var secondNames = second["results"]!.AsArray() .Select(row => row!["callerName"]!.GetValue()) .ToArray(); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 5e364c426..f9c825567 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; @@ -1661,6 +1662,59 @@ public void ParseArgs_RankByFlagParsed(string value, ReferenceRankMode expected) Assert.Equal("Target", options.Query); } + [Fact] + public void RunCallers_CountRankingControlsHumanAndJsonOrderAndPublishesRecipe_Issue4881() + { + var projectRoot = TestProjectHelper.CreateTempProject("caller_rank_recipe"); + var dbPath = CreateCallerRankingFixtureDb(projectRoot); + + var (jsonExitCode, jsonStdOut, jsonStdErr) = RunCallersInProcess( + "RankedTarget", + dbPath, + "csharp", + true, + "--rank-by", + "count"); + Assert.True( + jsonExitCode == CommandExitCodes.Success, + $"Expected success, got {jsonExitCode}.{Environment.NewLine}stdout:{Environment.NewLine}{jsonStdOut}{Environment.NewLine}stderr:{Environment.NewLine}{jsonStdErr}"); + Assert.Equal(string.Empty, jsonStdErr); + var jsonRows = jsonStdOut + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonNode.Parse(line)!) + .Where(row => row["path"] != null) + .ToArray(); + Assert.Equal("tests/HighVolumeTests.cs", jsonRows[0]["path"]!.GetValue()); + Assert.Equal("src/ProductionCaller.cs", jsonRows[1]["path"]!.GetValue()); + + var queryContext = jsonRows[0]["query_context"]!; + Assert.Equal("count", queryContext["rank_by"]!.GetValue()); + var recipe = queryContext["ranking_recipe"]!; + Assert.Equal("count", recipe["mode"]!.GetValue()); + Assert.Equal( + "reference_count_desc", + recipe["precedence"]![0]!.GetValue()); + Assert.Equal( + "path_category_asc", + recipe["precedence"]![3]!.GetValue()); + + var (humanExitCode, humanStdOut, humanStdErr) = CaptureConsole( + () => QueryCommandRunner.RunCallers( + [ + "RankedTarget", + "--db", dbPath, + "--lang", "csharp", + "--exact-name", + "--rank-by", "count", + ], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Contains("(2 callers in 2 files)", humanStdErr, StringComparison.Ordinal); + Assert.True( + humanStdOut.IndexOf("tests/HighVolumeTests.cs", StringComparison.Ordinal) + < humanStdOut.IndexOf("src/ProductionCaller.cs", StringComparison.Ordinal)); + } + [Fact] public void ParseArgs_InvalidRankByReportsParseError() { @@ -9005,6 +9059,45 @@ public void HandleRequest() { } return dbPath; } + private static string CreateCallerRankingFixtureDb(string projectRoot) + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + + InsertCaller("tests/HighVolumeTests.cs", "HighVolumeTests", 284); + InsertCaller("src/ProductionCaller.cs", "ProductionCaller", 1); + writer.MarkGraphReady(); + writer.MarkFoldReady(); + return dbPath; + + void InsertCaller(string path, string containerName, int count) + { + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = count, + Lines = count, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertReferences( + Enumerable.Range(1, count) + .Select(line => new ReferenceRecord + { + FileId = fileId, + SymbolName = "RankedTarget", + ReferenceKind = "call", + Line = line, + Column = 9, + Context = "RankedTarget();", + ContainerKind = "function", + ContainerName = containerName, + }) + .ToArray()); + } + } + private static string CreateHotspotFamilyFixtureDb(string projectRoot, bool markHotspotFamilyReady) { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);