diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e3bb429b2..f2145da5d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1,5 +1,14 @@ # Testing Guide +Dependency summary regressions in `QueryCommandRunnerGraphTests` (#5346) separate +page counts, SQL/C# candidate boundaries, extraction completeness, and response +budgets. Keep the three-edge limits 1/2/3/4, empty/filter/missing-graph controls, +batch child metadata, and the 201-symbol single-edge source-budget fixture. +Include mixed C#/SQL scope readiness and a filtered Markdown lookahead so +whole-query authority and page-window omissions cannot inherit page-only evidence. +Cycle summary variants share the existing ranked-SCC fixture in +`QueryCommandRunnerTests`; run these and dependency query regressions on net8/net9. + `Extract_CSharpStaticLambdaGate_BoundsRepeatedSameLineDeclarations` checks 64 same-line static methods with a warmed 2 MiB allocation ceiling on both runtimes, including C#, Razor, Blazor and CSHTML. Preserve all identities, raw start columns, @@ -1409,6 +1418,16 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 # テストガイド +`QueryCommandRunnerGraphTests` の依存関係 summary 回帰テスト(#5346)は、ページ件数、 +SQL/C# の候補上限、抽出の完全性、応答サイズ上限を区別します。3 edge に対する +limit 1/2/3/4、空結果・フィルター・グラフ欠落、batch の子メタデータ、201 symbol +から1 edgeを作る候補上限 fixture を維持してください。 +混在する C#/SQL の範囲全体の readiness と、フィルターで除去される Markdown の +先読みも検証し、総件数の authority とページ範囲の省略を返却行だけから判断しないでください。 +循環 summary は +`QueryCommandRunnerTests` の既存の SCC 順位 fixture を共有し、依存クエリの回帰と +併せて net8/net9 で実行します。 + #5339 は #5332 の fixture に正常な `files --format count --json` の batch を追加し、 3件・12件ともスナップショット3個分のコピー量を上限として、件数・鮮度・確定性の出力を検証します。 メタデータがある場合と欠落する場合の両方で、reader 取得後に元 DB のルート、大小文字区別設定、 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ceac2d670..f907f3716 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2754,7 +2754,7 @@ same source location. | `--quiet`, `-q`, `--silent` | All CLI commands | Suppress informational stderr without changing result stdout; errors remain visible. The flag can appear before or after the command. Use `--` before a query that literally starts with one of these tokens. | | `--pretty` | JSON-capable commands except `mcp` | Pretty-print JSON output with indentation. Default `search --json` remains newline-delimited; use `search --json=array --pretty` for an indented search result array. | | `--compact` | `map`, `inspect`, `outline` | Emit AI-oriented compact JSON with capped list sections and `truncation.sections.*` metadata. The default cap is 5 unless `--limit` / `--top` is supplied. | -| `--summary-only` | `map`, `recipes`, `audit`, `deps`, `hotspots`, and supported `search` JSON contexts | Emit aggregate/context JSON while omitting heavy result arrays where supported. For `deps`, use `--json` or `--format json-graph`; for `hotspots`, use `--json`. Machine-readable `deps` output emits `Progress:` diagnostics only with `--verbose`; other large graph queries emit them at `--limit 80+` or with `--verbose`. | +| `--summary-only` | `map`, `recipes`, `audit`, `deps`, `hotspots`, and supported `search` JSON contexts | Emit aggregate/context JSON while omitting heavy result arrays where supported. For `deps`, use `--json`; its count is a returned/page count with separate [count and completeness metadata](docs/deps-summary-counts.md#english), and `--format json-graph` is unsupported in summary mode. For `hotspots`, use `--json`. Machine-readable `deps` output emits `Progress:` diagnostics only with `--verbose`; other large graph queries emit them at `--limit 80+` or with `--verbose`. | | `--sort ` | `symbols`, `outline` | For `outline`, sort one file's symbols by `source`, `kind`, `references`, `size` / `span`, `complexity`, `path`, or `name` before `--limit` / cursor paging. | | `--outline-fields ` | `outline` | Project outline JSON symbol fields such as `name`, `line`, `kind`, `signature`, `container`, `range`, `body`, `reference_count`, `size_lines`, `complexity_score`, or `sort_mode`; pass `all` for the full symbol payload with paging metadata. | | `--fields ` | `inspect` | Select top-level inspect JSON groups or one-level collection leaves such as `definitions.name`, `definitions.path`, `references.line`, and `callers.path`. A parent keeps full rows and wins over its children; aliases, duplicates, and output order are normalized deterministically. `body` includes definition bodies and maps to `definitions`. Use `list` for the queryless typed catalog. | @@ -6810,7 +6810,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--quiet`、`-q`、`--silent` | 全 CLI コマンド | 結果の stdout を変えずに informational stderr を抑制し、エラーは表示する。フラグはコマンドの前後どちらにも指定できる。これらのトークン自体で始まるクエリを検索する場合は、その前に `--` を指定する。 | | `--pretty` | `mcp` を除く JSON 対応コマンド | JSON 出力をインデント付きで整形。既定の `search --json` は newline-delimited のまま維持されるため、検索結果配列を整形したい場合は `search --json=array --pretty` を使う。 | | `--compact` | `map`、`inspect`、`outline` | list section を cap した AI 向け compact JSON を出力し、`truncation.sections.*` metadata を含める。既定 cap は 5 件で、`--limit` / `--top` 指定時はその値を使う。 | -| `--summary-only` | `map`、`recipes`、`audit`、`deps`、`hotspots`、および対応する `search` JSON 文脈 | 対応コマンドで重い結果配列を省き、集計と文脈中心の JSON を返す。`deps` では `--json` または `--format json-graph`、`hotspots` では `--json` と組み合わせる。machine-readable な `deps` 出力は `--verbose` 指定時だけ stderr へ `Progress:` 診断を出し、それ以外の大きい graph query は `--limit 80` 以上または `--verbose` 指定時に出す。 | +| `--summary-only` | `map`、`recipes`、`audit`、`deps`、`hotspots`、および対応する `search` JSON 文脈 | 対応コマンドで重い結果配列を省き、集計と文脈中心の JSON を返す。`deps` では `--json` と組み合わせ、返却ページの件数と独立した[件数・完全性メタデータ](docs/deps-summary-counts.md#日本語)を返す。summary で `--format json-graph` は非対応。`hotspots` では `--json` と組み合わせる。machine-readable な `deps` 出力は `--verbose` 指定時だけ stderr へ `Progress:` 診断を出し、それ以外の大きい graph query は `--limit 80` 以上または `--verbose` 指定時に出す。 | | `--sort ` | `symbols`、`outline` | `outline` では 1ファイル内のシンボルを `source`、`kind`、`references`、`size` / `span`、`complexity`、`path`、`name` で並べ替えてから `--limit` / カーソルページングを適用する。 | | `--outline-fields ` | `outline` | outline JSON のシンボルフィールドを投影する。`name`、`line`、`kind`、`signature`、`container`、`range`、`body`、`reference_count`、`size_lines`、`complexity_score`、`sort_mode` などを指定でき、`all` を渡すとシンボルペイロード全体とページングメタデータを返す。 | | `--fields ` | `inspect` | inspect JSON の top-level group または `definitions.name`、`definitions.path`、`references.line`、`callers.path` など 1 階層の collection leaf を選択する。parent は row 全体を保持して child より優先され、alias、重複、出力順は決定的に正規化される。`body` は definition body を含め、`definitions` に対応する。query 不要の型付き catalog は `list` で取得できる。 | diff --git a/changelog.d/unreleased/5346.fixed.md b/changelog.d/unreleased/5346.fixed.md new file mode 100644 index 000000000..81e747301 --- /dev/null +++ b/changelog.d/unreleased/5346.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5346 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Database/DbReader.DependencyQueryExecution.cs + - docs/deps-summary-counts.md +--- + +## English + +- **Dependency summaries distinguish returned counts from complete totals (#5346)** — `deps --summary-only --json` now reports count units, additional-result evidence, candidate coverage, query exhaustion, and total-count availability/authority. Bounded lookahead preserves existing candidate budgets; cycle summaries reuse their analysis totals, and incomplete graphs or stale SQL contracts anywhere in the query scope cannot make totals authoritative. + +## 日本語 + +- **依存関係 summary が返却件数と完全な総件数を区別します (#5346)** — `deps --summary-only --json` は件数の単位、追加結果の証拠、候補範囲、検索完了、総件数の確定可否と authority を報告します。先読みは既存の候補上限を維持し、循環 summary は解析済みの総件数を再利用します。不完全なグラフやクエリ範囲内の古い SQL 契約がある場合は、総件数を authoritative としません。 diff --git a/docs/deps-summary-counts.md b/docs/deps-summary-counts.md new file mode 100644 index 000000000..a8b8826dd --- /dev/null +++ b/docs/deps-summary-counts.md @@ -0,0 +1,105 @@ +# Dependency summary counts + +## English + +`cdidx deps --summary-only --json --limit 2 --exclude-tests` reports the +same **returned/page count** as the corresponding dependency query. Omitting +the edge array does not turn `count` into a whole-graph total. This also applies +to `--cycles --summary-only`: its unit is a dependency cycle (SCC). + +| Field | Meaning | +| --- | --- | +| `count`, `returned_count` | Dependency rows selected for this page after filters; summary mode omits the edge array. | +| `count_kind`, `count_unit` | `returned`, with unit `dependency_edges` or `dependency_cycles`. | +| `page_limit` | Requested display limit; distinct from candidate and graph work budgets. | +| `has_more` | `true` proves an additional matching row was observed. `false` means the available query result was exhausted; `null` means exhaustion could not be established within the candidate window. For cycles it retains the existing meaning: more SCCs in the analyzed candidate graph. | +| `candidate_scan_complete` | No candidate safety boundary was reached. For cycles this matches `analysis_complete`; it does not establish extraction completeness. | +| `query_exhausted` | Candidate coverage is complete and there is no later page. This proves exhaustion of the indexed query, independently of extraction coverage. | +| `total_count`, `total_count_available` | Exact indexed-query total when already known, otherwise `null` / `false`. Ordinary summaries expose it only on exhaustion. Cycles can reuse the total from complete SCC analysis even on a partial page. | +| `total_count_authoritative` | The total is available, the reference graph is complete, its graph contract is not degraded, and workspace graph coverage is verified. It is not a claim of compiler-complete dependency analysis. | +| `total_count_unavailable_reason` | `page_limit`, `candidate_scan_incomplete`, `workspace_candidate_scan_unverified`, or `graph_edge_budget`; `null` when available. | +| `total_count_non_authoritative_reason` | When a numeric total is available but not authoritative: `reference_graph_incomplete`, `graph_contract_degraded`, or `workspace_graph_coverage_unverified`. | +| `truncated`, `truncated_reason` | Ordinary summaries identify a partial page or unverified candidate coverage. Cycles retain their existing page/graph-budget truncation contract. | + +On three matching edges, limits 1 and 2 return their respective counts with +`has_more=true`, `query_exhausted=false`, and no total. Limits 3 and above return +3 with `has_more=false` and `query_exhausted=true`, unless a candidate boundary +prevents that proof. An exhausted zero-row query can report a total of zero; +an incomplete or missing graph still prevents authoritative absence claims. + +Ordinary summaries look ahead by at most one **ranked result** inside the +existing SQL ranking window; they do not increase that window or either C# +source-candidate budget. C# name candidates and resolved-identity reference +candidates are checked separately before relying on the edge count. Reaching +a candidate boundary, even exactly, is conservatively incomplete. Filters can +remove the lookahead row, so an unexamined remainder remains unknown. +That filtered remainder uses `page_limit`, even when `candidate_scan_complete=true`. +Workspace fan-out currently lacks combined candidate-exhaustion evidence: +its ordinary summary preserves the existing query budget and reports unknown +`has_more` and an unavailable total. Narrow to one database for this proof. + +The broad-summary guard (250 candidate files without a narrowing filter), +generated/test exclusions, noise/evidence filters, and incomplete-graph warnings +remain in force. `--summary-only --format json-graph` remains unsupported. +`reference_graph_complete=true` alone never proves query exhaustion. +Cycle node sampling (`display_truncated`) is separate from SCC pagination. +Summary authority retains SQL readiness for the full query scope, including SQL +files that produce no returned edge or SCC. A non-SQL page cannot hide that degradation. + +`--max-json-bytes` is a response budget, not a query-work budget. A summary that +cannot fit returns `E028_RESPONSE_BUDGET_TOO_SMALL`; it does not silently discard +the count/coverage metadata or return an empty success. Successful batch child +summary payloads carry the same fields. These additions describe CLI summary +output; detailed output and MCP retain their existing contracts. + +## 日本語 + +`cdidx deps --summary-only --json --limit 2 --exclude-tests` の `count` は、 +対応する依存関係クエリと同じ **返却ページの件数** です。edge 配列を省略しても、 +グラフ全体の総件数にはなりません。`--cycles --summary-only` でも同様で、 +件数の単位は依存循環(SCC)です。 + +| フィールド | 意味 | +| --- | --- | +| `count`、`returned_count` | フィルター後にこのページへ選択された依存関係の件数。summary では edge 配列を省略します。 | +| `count_kind`、`count_unit` | `returned` と、単位 `dependency_edges` または `dependency_cycles`。 | +| `page_limit` | 要求された表示上限。候補走査やグラフ解析の処理量上限とは別です。 | +| `has_more` | `true` は追加の一致を確認済み、`false` は利用できるクエリ結果を走査済み、`null` は候補範囲内では完了を証明できなかったことを示します。cycles では従来どおり、解析対象の候補グラフに後続の SCC があるかを示します。 | +| `candidate_scan_complete` | 候補の安全上限に達していないこと。cycles では `analysis_complete` と一致し、抽出の完全性は保証しません。 | +| `query_exhausted` | 候補範囲の確認が完了し、後続ページがないこと。索引に対するクエリの完了を示し、抽出範囲とは独立です。 | +| `total_count`、`total_count_available` | 既知の場合は索引に対するクエリの正確な総件数、それ以外は `null` / `false`。通常の summary は完了時のみ返します。cycles は完全な SCC 解析の総件数を部分ページでも再利用できます。 | +| `total_count_authoritative` | 総件数が既知で、参照グラフが完全かつ契約が縮退しておらず、workspace のグラフ範囲も確認済みであること。コンパイラーと同等の依存解析を保証するものではありません。 | +| `total_count_unavailable_reason` | `page_limit`、`candidate_scan_incomplete`、`workspace_candidate_scan_unverified`、`graph_edge_budget`。総件数が既知なら `null`。 | +| `total_count_non_authoritative_reason` | 数値の総件数が既知でも authoritative ではない理由。`reference_graph_incomplete`、`graph_contract_degraded`、`workspace_graph_coverage_unverified`。 | +| `truncated`、`truncated_reason` | 通常の summary では部分ページまたは未確認の候補範囲を示します。cycles は従来のページ/グラフ上限の契約を維持します。 | + +一致する edge が3件なら、limit 1/2 はそれぞれの返却件数とともに +`has_more=true`、`query_exhausted=false`、総件数未確定を返します。 +limit 3以上は、候補上限によって証明が妨げられない限り、3件と +`has_more=false`、`query_exhausted=true` を返します。0件の完了クエリも +総件数0を返せますが、抽出が不完全、またはグラフがない場合は +authoritative な不在の証拠にはなりません。 + +通常の summary は既存の SQL ranking window 内で、順位付きの結果を最大1件だけ +先読みします。この範囲や C# の候補上限は増やしません。C# の名前候補と +解決済み参照の候補を別々に確認し、edge 件数だけから完了を推測しません。 +候補上限ちょうどの場合も安全側に倒して未完了とします。フィルターが先読み結果を +除去した場合、未確認の残りがあれば追加結果の有無は不明です。 +この場合は `candidate_scan_complete=true` でも省略理由を `page_limit` とします。 +複数 DB の workspace 集計には候補範囲を統合した完了証拠がないため、既存の +処理量上限を維持して `has_more` を不明、総件数を未確定とします。 +完了を確認する場合は単一 DB に絞ってください。 + +絞り込みがない場合の250候補ファイルの guard、生成コード/テスト除外、 +noise/参照証拠のフィルター、不完全なグラフの警告は維持します。 +`--summary-only --format json-graph` は引き続き非対応です。 +`reference_graph_complete=true` だけでは検索完了を証明できません。 +循環内の node の表示省略(`display_truncated`)も SCC のページングとは別です。 +summary の authority は、返却 edge や SCC を生成しなかった SQL ファイルも含む +クエリ全体の SQL readiness を確認します。ページに SQL がなくても縮退状態は隠しません。 + +`--max-json-bytes` は応答サイズの上限であり、クエリの処理量上限ではありません。 +summary が収まらなければ `E028_RESPONSE_BUDGET_TOO_SMALL` を返し、件数や範囲の +メタデータを黙って捨てたり、空の成功応答を返したりしません。成功した batch の +子 summary も同じフィールドを持ちます。この追加は CLI の summary 出力が対象で、 +詳細出力と MCP は既存の契約を維持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index b56587ebd..2fcce4b4c 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -576,7 +576,7 @@ private static IReadOnlyList BuildAll() Description = "Map: comma-separated response sections to include, or list to discover sections", PrimaryCommands = Set(MapSectionCommands), }, - new() { Name = "--summary-only", Description = "Map/Diff/Recipes/Audit/Files/Symbols/Deps/Hotspots/Languages: return only aggregate summary fields where supported", PrimaryCommands = Set(SummaryOnlyCommands) }, + new() { Name = "--summary-only", Description = "Map/Diff/Recipes/Audit/Files/Symbols/Deps/Hotspots/Languages: return aggregate summary fields where supported; Deps counts returned rows and reports query exhaustion separately from graph completeness", PrimaryCommands = Set(SummaryOnlyCommands) }, new() { Name = "--detailed", Description = "Diff: compare deterministic row-level records", PrimaryCommands = Set("diff") }, new() { Name = "--include-content", Description = "Diff detailed JSON: include indexed content instead of redacted hashes", PrimaryCommands = Set("diff") }, new() { Name = "--data-only", Description = "Diff: include indexed data and schema in identity while excluding readiness/provenance and volatile telemetry", PrimaryCommands = Set("diff") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index e41afe10d..e4fc7d7d7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -687,6 +687,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C List results; List cycleCandidates; + DependencySummaryCoverage? summaryCoverage = null; var cycleCandidateRowCount = 0; var cycleGraphBudget = options.DependencyCycleGraphBudget; var cyclePageOffset = options.DependencyCycleCursor?.Offset ?? 0; @@ -707,7 +708,29 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C else { WriteGraphLiveness("deps", "read_edges", options, depsFormat, machineReadable: machineReadable); - results = GetWorkspaceFileDependencies(reader, options, reverse, options.Limit, cancellationToken); + if (options.SummaryOnly && options.WorkspaceDbPaths.Count == 0) + { + var page = reader.GetFileDependencySummaryPage( + options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, + options.ExcludeTests, reverse, cancellationToken, options.DependencySymbols, + options.DependencySymbolFamilies, options.DependencySuppressNoise, + options.DependencyEvidenceFilter); + results = page.Edges.Take(options.Limit).ToList(); + var lookahead = ApplyDependencySymbolFilters(page.Edges.Skip(options.Limit).ToList(), options); + summaryCoverage = new( + lookahead.Edges.Count > 0 ? true + : page.CandidateScanComplete && page.ResultWindowComplete ? false : null, + page.CandidateScanComplete, + page.CandidateScanComplete ? "page_limit" : "candidate_scan_incomplete"); + } + else + { + results = GetWorkspaceFileDependencies(reader, options, reverse, options.Limit, cancellationToken); + // Workspace fan-out does not currently expose every member's + // candidate exhaustion. Preserve its work budget and fail closed. + if (options.SummaryOnly) + summaryCoverage = new(null, false, "workspace_candidate_scan_unverified"); + } cycleCandidates = results; } WriteGraphLiveness("deps", "shape_output", options, depsFormat, rows: results.Count, machineReadable: machineReadable); @@ -785,7 +808,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C AddDependencyGraphAvailabilityJsonFields(payload, reader._hasReferencesTable); AddFreshnessHint(payload, reader); WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: 0, cycleCount: 0, machineReadable: machineReadable); - var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions); + var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; } @@ -812,7 +835,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C if (options.SummaryOnly) payload["summary_only"] = true; payload["note"] = "symbol_references table is missing in this index (legacy or read-only DB). Zero result is degraded, not authoritative."; - var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions); + var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; } else if (options.Json) @@ -820,7 +843,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C var payload = BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: options.SummaryOnly ? null : "edges", graphTableAvailable: true, degraded: !zeroSqlGraphSignal.Ready, queryOptions: options, extraFields: payload => AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, zeroSqlGraphSignal, zeroSymbolFilter)); if (options.SummaryOnly) payload["summary_only"] = true; - var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions); + var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; } else @@ -881,7 +904,10 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C ? cycles.SelectMany(static cycle => cycle) : symbolFilter.Edges.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }) : outputEdges.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }); - var sqlGraphSignal = options.GroupDependencyPartialTypes && reader.DependencyCycleGroupingReady + // Summary totals cover the query scope, including stale SQL paths + // that yield no returned edge or SCC on this page. + var sqlGraphSignal = options.SummaryOnly + || (options.GroupDependencyPartialTypes && reader.DependencyCycleGroupingReady) ? baseSqlGraphSignal : NarrowSqlGraphContractSignalByPaths( reader, @@ -907,7 +933,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C extraFields: payload => AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, sqlGraphSignal, symbolFilter.Summary)); if (options.SummaryOnly) payload["summary_only"] = true; - var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions); + var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; } else @@ -937,7 +963,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, sqlGraphSignal, symbolFilter.Summary); AddFreshnessHint(payload, reader); WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: outputEdges.Count, cycleCount: 0, machineReadable: machineReadable); - var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions); + var writeExitCode = WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; } else @@ -1031,7 +1057,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, sqlGraphSignal, symbolFilter.Summary); AddFreshnessHint(payload, reader); WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: outputEdges.Count, cycleCount: cycles.Count, machineReadable: machineReadable); - return WriteDepsJsonPayload(payload, options, jsonOptions); + return WriteDepsJsonPayload(payload, options, jsonOptions, summaryCoverage); } else { @@ -2111,14 +2137,73 @@ private static JsonArray BuildDependencyEvidenceJson(IReadOnlyList depsFormat == OutputFormatJsonGraph || (options.Json && depsFormat == OutputFormatEdgeList); - private static int WriteDepsJsonPayload(JsonObject payload, QueryCommandOptions options, JsonSerializerOptions jsonOptions) - => WriteJsonPayloadWithOptionalByteLimit( + private sealed record DependencySummaryCoverage(bool? HasMore, bool CandidateScanComplete, string IncompleteReason); + + private static int WriteDepsJsonPayload( + JsonObject payload, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + DependencySummaryCoverage? summaryCoverage = null) + { + if (options.SummaryOnly) + AddDependencySummaryCountFields(payload, options, summaryCoverage); + return WriteJsonPayloadWithOptionalByteLimit( payload, options, jsonOptions, "deps", "deps", "Use --summary-only, reduce --limit, or increase --max-json-bytes."); + } + + private static void AddDependencySummaryCountFields( + JsonObject payload, + QueryCommandOptions options, + DependencySummaryCoverage? coverage) + { + var count = payload["count"]!.GetValue(); + var candidateScanComplete = options.DependencyCycles + ? payload["analysis_complete"]?.GetValue() == true + : coverage?.CandidateScanComplete == true; + var hasMore = options.DependencyCycles + ? payload["has_more"]?.GetValue() + : coverage?.HasMore; + var queryExhausted = candidateScanComplete && hasMore == false; + int? totalCount = options.DependencyCycles && candidateScanComplete + ? payload["total_cycle_count"]!.GetValue() + : queryExhausted ? count : null; + var totalAuthoritative = totalCount.HasValue + && payload["reference_graph_complete"]?.GetValue() == true + && payload["degraded"]?.GetValue() != true + && options.WorkspaceDbPaths.Count == 0; + + payload["count_kind"] = "returned"; + payload["count_unit"] = options.DependencyCycles ? "dependency_cycles" : "dependency_edges"; + payload["returned_count"] = count; + payload["page_limit"] = options.Limit; + payload["has_more"] = hasMore; + payload["candidate_scan_complete"] = candidateScanComplete; + payload["query_exhausted"] = queryExhausted; + payload["total_count"] = totalCount; + payload["total_count_available"] = totalCount.HasValue; + payload["total_count_authoritative"] = totalAuthoritative; + payload["total_count_unavailable_reason"] = totalCount.HasValue ? null + : !candidateScanComplete + ? options.DependencyCycles ? "graph_edge_budget" : coverage?.IncompleteReason ?? "candidate_scan_incomplete" + : "page_limit"; + if (totalCount.HasValue && !totalAuthoritative) + payload["total_count_non_authoritative_reason"] = options.WorkspaceDbPaths.Count > 0 + ? "workspace_graph_coverage_unverified" + : payload["reference_graph_complete"]?.GetValue() != true + ? "reference_graph_incomplete" : "graph_contract_degraded"; + + if (!options.DependencyCycles) + { + payload["truncated"] = !queryExhausted; + if (!queryExhausted) + payload["truncated_reason"] = hasMore == true ? "page_limit" : coverage?.IncompleteReason; + } + } internal sealed record DependencySymbolFilterResult(List Edges, DependencySymbolFilterSummary Summary); diff --git a/src/CodeIndex/Database/DbReader.Dependencies.cs b/src/CodeIndex/Database/DbReader.Dependencies.cs index e48ca6ae2..69c59e090 100644 --- a/src/CodeIndex/Database/DbReader.Dependencies.cs +++ b/src/CodeIndex/Database/DbReader.Dependencies.cs @@ -251,6 +251,29 @@ public List GetFileDependencies( dependencySymbolFamilies, suppressDependencyNoise, evidenceFilter); + return ExecuteDependencyQuery(BuildDependencyQueryPlan(request), cancellationToken).Edges; + } + + internal DependencyQueryResult GetFileDependencySummaryPage( + int limit, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + bool reverse, + CancellationToken cancellationToken, + IReadOnlyList? dependencySymbols, + IReadOnlyList? dependencySymbolFamilies, + bool suppressDependencyNoise, + DependencyEvidenceFilter? evidenceFilter) + { + if (!_hasReferencesTable) + return new([], false, true); + + var request = new DependencyQueryRequest( + limit, NormalizeQueryLanguage(lang), pathPatterns, excludePathPatterns, + excludeTests, reverse, dependencySymbols, dependencySymbolFamilies, + suppressDependencyNoise, evidenceFilter, CaptureSummaryCoverage: true); return ExecuteDependencyQuery(BuildDependencyQueryPlan(request), cancellationToken); } } diff --git a/src/CodeIndex/Database/DbReader.DependencyFinalSql.cs b/src/CodeIndex/Database/DbReader.DependencyFinalSql.cs index 67e054176..98c284f0f 100644 --- a/src/CodeIndex/Database/DbReader.DependencyFinalSql.cs +++ b/src/CodeIndex/Database/DbReader.DependencyFinalSql.cs @@ -7,7 +7,7 @@ private static DependencySqlFragment BuildDependencyFinalSql(DependencyQueryRequ var sql = new DependencySqlFragmentBuilder(); AppendDependencyEdgeTotals(sql, request.SuppressDependencyNoise); AppendDependencyEvidence(sql); - AppendDependencySymbolsAndSelect(sql, request.SuppressDependencyNoise); + AppendDependencySymbolsAndSelect(sql, request); return sql.Build(); } @@ -86,8 +86,9 @@ FROM ordered_edge_evidence private static void AppendDependencySymbolsAndSelect( DependencySqlFragmentBuilder sql, - bool suppressDependencyNoise) + DependencyQueryRequest request) { + var suppressDependencyNoise = request.SuppressDependencyNoise; var retainedFilterSql = suppressDependencyNoise ? " WHERE edges.origin <> 'markdown_heading_name_match'" : string.Empty; @@ -110,7 +111,10 @@ ranked_edge_symbols AS ( symbol_name, ROW_NUMBER() OVER (PARTITION BY source_path, target_path ORDER BY symbol_name) AS symbol_rank FROM distinct_edge_symbols - ) + )"); + if (request.CaptureSummaryCoverage) + sql.Append(", dependency_result_rows AS ("); + sql.Append(@" SELECT limited_edge_totals.source_path, limited_edge_totals.target_path, limited_edge_totals.reference_count, @@ -128,5 +132,18 @@ LEFT JOIN edge_evidence_payloads limited_edge_totals.reference_count, edge_evidence_payloads.evidence_payload ORDER BY " + finalOrderSql); + if (request.CaptureSummaryCoverage) + { + // Both C# source paths are bounded before aggregation. Hitting a + // boundary is conservatively incomplete, including an exact hit. + var sourceCompleteSql = request.Lang == "csharp" + ? "(SELECT COUNT(*) FROM bounded_source_name_counts) < @sourceCandidateLimit" + + " AND (SELECT COALESCE(SUM(ref_count), 0) FROM edges WHERE origin = 'resolved_identity') < @sourceCandidateLimit" + : "1"; + sql.Append(@") + SELECT dependency_result_rows.*, coverage.source_scan_complete + FROM (SELECT " + sourceCompleteSql + @" AS source_scan_complete) coverage + LEFT JOIN dependency_result_rows ON 1 = 1"); + } } } diff --git a/src/CodeIndex/Database/DbReader.DependencyQueryExecution.cs b/src/CodeIndex/Database/DbReader.DependencyQueryExecution.cs index 0b81bdc88..a5f16bddb 100644 --- a/src/CodeIndex/Database/DbReader.DependencyQueryExecution.cs +++ b/src/CodeIndex/Database/DbReader.DependencyQueryExecution.cs @@ -5,7 +5,12 @@ namespace CodeIndex.Database; public partial class DbReader { - private List ExecuteDependencyQuery( + internal sealed record DependencyQueryResult( + List Edges, + bool CandidateScanComplete, + bool ResultWindowComplete); + + private DependencyQueryResult ExecuteDependencyQuery( DependencyQueryPlan plan, CancellationToken cancellationToken) { @@ -14,6 +19,7 @@ private List ExecuteDependencyQuery( BindDependencyQueryParameters(command, plan.Parameters); var results = new List(); + var sourceScanComplete = false; cancellationToken.ThrowIfCancellationRequested(); using var cancellationRegistration = cancellationToken.Register( static state => ((SqliteCommand)state!).Cancel(), @@ -24,6 +30,13 @@ private List ExecuteDependencyQuery( while (reader.TrackedRead()) { cancellationToken.ThrowIfCancellationRequested(); + if (plan.Request.CaptureSummaryCoverage) + { + sourceScanComplete = reader.GetBoolean(5); + // The coverage row survives an empty edge result. + if (reader.IsDBNull(0)) + continue; + } results.Add(ProjectDependencyRow(reader)); } } @@ -32,9 +45,17 @@ private List ExecuteDependencyQuery( throw new OperationCanceledException(cancellationToken); } - return RankDependencyResults( - results, - plan.Request.Limit, - plan.Request.SuppressDependencyNoise); + // Reuse the existing bounded ranking window. Looking ahead must not + // enlarge either the SQL candidate budget or the C# source budget. + var outputLimit = plan.Request.CaptureSummaryCoverage && plan.Request.Limit < int.MaxValue + ? plan.Request.Limit + 1 + : plan.Request.Limit; + var candidateScanComplete = sourceScanComplete + && results.Count < DependencyNoiseProfile.GetRankingCandidateLimit(plan.Request.Limit); + var resultWindowComplete = results.Count <= outputLimit; + return new( + RankDependencyResults(results, outputLimit, plan.Request.SuppressDependencyNoise), + candidateScanComplete, + resultWindowComplete); } } diff --git a/src/CodeIndex/Database/DbReader.DependencyQueryPlan.cs b/src/CodeIndex/Database/DbReader.DependencyQueryPlan.cs index 9ea628271..661ed2d0a 100644 --- a/src/CodeIndex/Database/DbReader.DependencyQueryPlan.cs +++ b/src/CodeIndex/Database/DbReader.DependencyQueryPlan.cs @@ -17,7 +17,8 @@ private sealed record DependencyQueryRequest( IReadOnlyList? DependencySymbolFamilies, bool SuppressDependencyNoise, DependencyEvidenceFilter? EvidenceFilter = null, - bool GroupPartialTypes = false); + bool GroupPartialTypes = false, + bool CaptureSummaryCoverage = false); private sealed record DependencyQueryPlan( DependencyQueryRequest Request, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs index a67a39afc..d69ac4589 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs @@ -53,7 +53,13 @@ FROM files Assert.Equal(CommandExitCodes.Success, depsExitCode); Assert.Equal(string.Empty, depsStderr); using (var depsDocument = ParseJsonOutput(depsStdout)) + { AssertReferenceGraphIncomplete(depsDocument.RootElement); + Assert.True(depsDocument.RootElement.GetProperty("query_exhausted").GetBoolean()); + Assert.Equal(0, depsDocument.RootElement.GetProperty("total_count").GetInt32()); + Assert.False(depsDocument.RootElement.GetProperty("total_count_authoritative").GetBoolean()); + Assert.Equal("reference_graph_incomplete", depsDocument.RootElement.GetProperty("total_count_non_authoritative_reason").GetString()); + } var (impactExitCode, impactStdout, impactStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["MissingSymbol", "--db", dbPath, "--json"], @@ -214,6 +220,233 @@ public void RunDeps_JsonSummaryModesShareGraphFixture_Issues4112And4353And4450() } } + [Fact] + public void RunDeps_JsonSummaryReportsPageAndCandidateAuthority_Issue5346() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_summary_authority"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.WriteTextFile(project.Root, "src/Target.cs", "public class SummaryTarget { }"); + foreach (var path in new[] { "src/First.cs", "src/Second.cs", "src/Third.cs", "tests/CallerTests.cs" }) + TestProjectHelper.WriteTextFile(project.Root, path, + $"public class {Path.GetFileNameWithoutExtension(path)} {{ public SummaryTarget Run() => new SummaryTarget(); }}"); + TestProjectHelper.WriteTextFile(project.Root, "src/Generated.g.cs", + "// \npublic class Generated { public SummaryTarget Run() => new SummaryTarget(); }"); + var (indexExit, _, _) = CaptureConsole(() => IndexCommandRunner.Run( + [project.Root, "--db", dbPath, "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExit); + + foreach (var limit in new[] { 1, 2, 3, 4 }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--summary-only", "--lang", "csharp", "--exclude-tests", "--limit", limit.ToString(System.Globalization.CultureInfo.InvariantCulture)], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + Assert.Equal(Math.Min(limit, 3), json.GetProperty("count").GetInt32()); + Assert.Equal(json.GetProperty("count").GetInt32(), json.GetProperty("returned_count").GetInt32()); + Assert.Equal("returned", json.GetProperty("count_kind").GetString()); + Assert.Equal("dependency_edges", json.GetProperty("count_unit").GetString()); + Assert.Equal(limit, json.GetProperty("page_limit").GetInt32()); + Assert.Equal(limit < 3, json.GetProperty("has_more").GetBoolean()); + Assert.Equal(limit < 3, json.GetProperty("truncated").GetBoolean()); + Assert.True(json.GetProperty("candidate_scan_complete").GetBoolean()); + Assert.Equal(limit >= 3, json.GetProperty("query_exhausted").GetBoolean()); + Assert.Equal(limit >= 3, json.GetProperty("total_count_available").GetBoolean()); + Assert.Equal(limit >= 3, json.GetProperty("total_count_authoritative").GetBoolean()); + if (limit >= 3) + Assert.Equal(3, json.GetProperty("total_count").GetInt32()); + else + { + Assert.Equal(JsonValueKind.Null, json.GetProperty("total_count").ValueKind); + Assert.Equal("page_limit", json.GetProperty("total_count_unavailable_reason").GetString()); + } + Assert.False(json.TryGetProperty("edges", out _)); + } + + foreach (var (filter, value, expectedCount) in new[] + { + ("--path", "src/First.cs", 1), + ("--path", "missing/**", 0), + ("--symbol", "MissingSymbol", 0), + ("--symbol-family", "Summary", 3), + }) + { + var (exitCode, stdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--summary-only", "--lang", "csharp", "--exclude-tests", filter, value, "--limit", "3", "--max-json-bytes", "4096"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(System.Text.Encoding.UTF8.GetByteCount(stdout) <= 4096); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + Assert.Equal(expectedCount, json.GetProperty("count").GetInt32()); + Assert.Equal(expectedCount, json.GetProperty("total_count").GetInt32()); + Assert.False(json.GetProperty("has_more").GetBoolean()); + Assert.True(json.GetProperty("query_exhausted").GetBoolean()); + } + + foreach (var filters in new[] + { + new[] { "--include-generated" }, + new[] { "--suppress-noise" }, + new[] { "--reference-kind", "call" }, + new[] { "--resolution-state", "resolved" }, + new[] { "--reverse", "--path", "src/Target.cs" }, + new[] { "--exclude-path", "src/Third.cs" }, + }) + { + var args = new[] { "--db", dbPath, "--json", "--lang", "csharp", "--exclude-tests", "--limit", "10" }.Concat(filters).ToArray(); + var (detailExit, detailStdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps(args, _jsonOptions)); + var (summaryExit, summaryStdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps([.. args, "--summary-only"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, detailExit); + Assert.Equal(detailExit, summaryExit); + using var detail = ParseJsonOutput(detailStdout); + using var summary = ParseJsonOutput(summaryStdout); + Assert.Equal(detail.RootElement.GetProperty("count").GetInt32(), summary.RootElement.GetProperty("count").GetInt32()); + Assert.True(summary.RootElement.GetProperty("query_exhausted").GetBoolean()); + if (filters[0] == "--include-generated") + Assert.Equal(4, summary.RootElement.GetProperty("count").GetInt32()); + } + + using var member = TestProjectHelper.CreateTempProjectScope("cdidx_deps_summary_member"); + var memberDb = TestProjectHelper.CreateProjectDb(member.Root); + var (workspaceExit, workspaceStdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--workspace-db", memberDb, "--json", "--summary-only", "--limit", "1", "--exclude-tests"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, workspaceExit); + using (var workspace = ParseJsonOutput(workspaceStdout)) + { + Assert.Equal(1, workspace.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(JsonValueKind.Null, workspace.RootElement.GetProperty("has_more").ValueKind); + Assert.False(workspace.RootElement.GetProperty("query_exhausted").GetBoolean()); + Assert.Equal("workspace_candidate_scan_unverified", workspace.RootElement.GetProperty("total_count_unavailable_reason").GetString()); + } + + var input = JsonSerializer.Serialize(new[] { "deps", "--json", "--summary-only", "--lang", "csharp", "--exclude-tests", "--limit", "1" }) + "\n"; + var (batchExit, batchStdout, batchStderr) = CaptureConsoleWithInput(input, + () => QueryCommandRunner.RunBatch(["--db", dbPath, "--json-summary"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, batchExit); + Assert.Empty(batchStderr); + var batchLines = ParseJsonLines(batchStdout); + try + { + var child = batchLines[0].RootElement.GetProperty("result"); + Assert.Equal(1, child.GetProperty("count").GetInt32()); + Assert.Equal("returned", child.GetProperty("count_kind").GetString()); + Assert.True(child.GetProperty("has_more").GetBoolean()); + Assert.False(child.GetProperty("query_exhausted").GetBoolean()); + } + finally + { + foreach (var line in batchLines) + line.Dispose(); + } + + var (budgetExit, budgetStdout, budgetStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--summary-only", "--limit", "1", "--max-json-bytes", "1"], _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, budgetExit); + Assert.Empty(budgetStderr); + using var budgetDocument = ParseJsonOutput(budgetStdout); + Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, budgetDocument.RootElement.GetProperty("error_code").GetString()); + } + + [Fact] + public void RunDeps_SummaryKeepsQueryWideSqlReadiness_Issue5346() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_summary_sql_readiness"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + foreach (var (name, target) in new[] { ("CycleA", "CycleB"), ("CycleB", "CycleA"), ("CycleC", "CycleD"), ("CycleD", "CycleC") }) + TestProjectHelper.WriteTextFile(project.Root, $"src/{name}.cs", + $"public class {name} {{ public {target} Next() => new {target}(); }}"); + TestProjectHelper.WriteTextFile(project.Root, "src/isolated.sql", "CREATE TABLE Isolated (Id INTEGER);"); + var (indexExit, _, _) = CaptureConsole(() => IndexCommandRunner.Run( + [project.Root, "--db", dbPath, "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExit); + + foreach (var sqlReady in new[] { false, true }) + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + if (sqlReady) + writer.MarkSqlGraphContractReady(); + else + writer.SetMeta(DbContext.SqlGraphContractVersionMetaKey, "0"); + } + + foreach (var (mode, total, exhausted) in new[] + { + (new[] { "--limit", "10" }, 4, true), + (new[] { "--cycles", "--limit", "1" }, 2, false), + (new[] { "--cycles", "--limit", "2" }, 2, true), + }) + { + var args = new[] { "--db", dbPath, "--json" }.Concat(mode).ToArray(); + var (summaryExit, summaryStdout, summaryStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + [.. args, "--summary-only"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, summaryExit); + Assert.Empty(summaryStderr); + using var document = ParseJsonOutput(summaryStdout); + var summary = document.RootElement; + Assert.True(summary.GetProperty("reference_graph_complete").GetBoolean()); + Assert.Equal(total, summary.GetProperty("total_count").GetInt32()); + Assert.Equal(exhausted, summary.GetProperty("query_exhausted").GetBoolean()); + Assert.Equal(sqlReady, summary.GetProperty("total_count_authoritative").GetBoolean()); + if (!sqlReady) + { + Assert.True(summary.GetProperty("degraded").GetBoolean()); + Assert.False(summary.GetProperty("sql_graph_contract_ready").GetBoolean()); + Assert.Equal("graph_contract_degraded", summary.GetProperty("total_count_non_authoritative_reason").GetString()); + } + + // Explicitly narrowing the query to C# excludes the stale SQL scope. + var (narrowExit, narrowStdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps( + [.. args, "--summary-only", "--lang", "csharp"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, narrowExit); + using var narrow = ParseJsonOutput(narrowStdout); + Assert.True(narrow.RootElement.GetProperty("total_count_authoritative").GetBoolean()); + + var (detailExit, detailStdout, _) = CaptureConsole(() => QueryCommandRunner.RunDeps(args, _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, detailExit); + using var detail = ParseJsonOutput(detailStdout); + Assert.False(detail.RootElement.TryGetProperty("degraded", out var degraded) && degraded.GetBoolean()); + } + } + } + + [Fact] + public void RunDeps_JsonSummaryDoesNotInferExhaustionFromBoundedCSharpCandidates_Issue5346() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_summary_candidate_cap"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + var names = Enumerable.Range(0, DependencyNoiseProfile.GetRankingCandidateLimit(1) + 1) + .Select(i => $"SummaryTarget{i}").ToArray(); + TestProjectHelper.WriteTextFile(project.Root, "src/Target.cs", + string.Join("\n", names.Select(name => $"public class {name} {{ }}"))); + TestProjectHelper.WriteTextFile(project.Root, "src/Caller.cs", + "public class Caller { public void Run() {\n" + + string.Join("\n", names.Select(name => $"_ = new {name}();")) + "\n} }"); + var (indexExit, _, _) = CaptureConsole(() => IndexCommandRunner.Run( + [project.Root, "--db", dbPath, "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExit); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--summary-only", "--lang", "csharp", "--limit", "2"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.True(json.GetProperty("reference_graph_complete").GetBoolean()); + Assert.False(json.GetProperty("candidate_scan_complete").GetBoolean()); + Assert.False(json.GetProperty("query_exhausted").GetBoolean()); + Assert.True(json.GetProperty("truncated").GetBoolean()); + Assert.Equal(JsonValueKind.Null, json.GetProperty("has_more").ValueKind); + Assert.Equal(JsonValueKind.Null, json.GetProperty("total_count").ValueKind); + Assert.False(json.GetProperty("total_count_authoritative").GetBoolean()); + Assert.Equal("candidate_scan_incomplete", json.GetProperty("total_count_unavailable_reason").GetString()); + } + [Fact] public void RunDeps_JsonFormatsShareMaxBytesFixture_Issue4112() { @@ -325,6 +558,9 @@ public void RunDeps_MissingGraphJsonModesShareZeroPayloadFixture_Issues4112And46 Assert.True(summaryJson.GetProperty("summary_only").GetBoolean()); Assert.True(summaryJson.GetProperty("degraded").GetBoolean()); Assert.False(summaryJson.TryGetProperty("edges", out _)); + Assert.False(summaryJson.GetProperty("query_exhausted").GetBoolean()); + Assert.False(summaryJson.GetProperty("total_count_authoritative").GetBoolean()); + Assert.Equal(JsonValueKind.Null, summaryJson.GetProperty("has_more").ValueKind); foreach (var (arguments, resultsKey) in new[] { @@ -374,6 +610,9 @@ public void RunDeps_JsonSummaryOnly_OmitsEdgesForZeroPayload_Issue4112() Assert.Equal(0, json.GetProperty("count").GetInt32()); Assert.True(json.GetProperty("summary_only").GetBoolean()); Assert.False(json.TryGetProperty("edges", out _)); + Assert.True(json.GetProperty("query_exhausted").GetBoolean()); + Assert.False(json.GetProperty("has_more").GetBoolean()); + Assert.Equal(0, json.GetProperty("total_count").GetInt32()); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index a00c44662..759d3aa46 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; @@ -7680,6 +7681,26 @@ public void RunDeps_CyclesUsesStableCompleteRankingAndCursorPagination_Issues318 nodes, expandedCycles[0].GetProperty("nodes").EnumerateArray().Select(node => node.GetString()).ToArray()); + foreach (var (summaryLimit, graphBudget, complete) in new[] { (1, 100, true), (2, 100, true), (1, 1, false) }) + { + var (summaryExit, summaryStdout, summaryStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--summary-only", "--limit", summaryLimit.ToString(CultureInfo.InvariantCulture), + "--graph-budget", graphBudget.ToString(CultureInfo.InvariantCulture), "--lang", "csharp"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, summaryExit); + Assert.Empty(summaryStderr); + using var summaryDocument = ParseJsonOutput(summaryStdout); + var summary = summaryDocument.RootElement; + Assert.Equal("returned", summary.GetProperty("count_kind").GetString()); + Assert.Equal("dependency_cycles", summary.GetProperty("count_unit").GetString()); + Assert.Equal(complete, summary.GetProperty("candidate_scan_complete").GetBoolean()); + Assert.Equal(complete && summaryLimit == 2, summary.GetProperty("query_exhausted").GetBoolean()); + Assert.Equal(complete, summary.GetProperty("total_count_available").GetBoolean()); + if (complete) + Assert.Equal(2, summary.GetProperty("total_count").GetInt32()); + else + Assert.Equal("graph_edge_budget", summary.GetProperty("total_count_unavailable_reason").GetString()); + } + var (mismatchExitCode, mismatchStdout, mismatchStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( ["--db", dbPath, "--json", "--cycles", "--limit", "1", "--graph-budget", "100", "--cursor", cursor!, "--lang", "csharp"], _jsonOptions)); @@ -8027,6 +8048,31 @@ public void RunDeps_SuppressNoiseRemovesLegacyMarkdownHeadingEvidenceButKeepsExp Assert.Equal(3, suppressionReason.GetProperty("edges_affected").GetInt32()); Assert.Equal(2, suppressionReason.GetProperty("edges_removed").GetInt32()); Assert.Equal(6_000, suppressionReason.GetProperty("references_removed").GetInt32()); + + foreach (var limit in new[] { 1, 3 }) + { + var (summaryExit, summaryStdout, summaryStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--summary-only", "--limit", limit.ToString(CultureInfo.InvariantCulture), "--lang", "markdown", "--suppress-noise"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, summaryExit); + Assert.Empty(summaryStderr); + using var summaryDocument = ParseJsonOutput(summaryStdout); + var summary = summaryDocument.RootElement; + Assert.Equal(1, summary.GetProperty("count").GetInt32()); + Assert.True(summary.GetProperty("candidate_scan_complete").GetBoolean()); + Assert.Equal(limit == 3, summary.GetProperty("query_exhausted").GetBoolean()); + if (limit == 1) + { + Assert.Equal(JsonValueKind.Null, summary.GetProperty("has_more").ValueKind); + Assert.Equal("page_limit", summary.GetProperty("truncated_reason").GetString()); + Assert.Equal("page_limit", summary.GetProperty("total_count_unavailable_reason").GetString()); + } + else + { + Assert.False(summary.GetProperty("has_more").GetBoolean()); + Assert.Equal(1, summary.GetProperty("total_count").GetInt32()); + } + } } [Fact] diff --git a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json index 0dd2f7687..6696f27b0 100644 --- a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json +++ b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json @@ -176,5 +176,13 @@ }, "indexed_file_count": 3, "indexed_at": "\u003CTIMESTAMP\u003E", - "freshness_available": true + "freshness_available": true, + "count_kind": "returned", + "count_unit": "dependency_cycles", + "candidate_scan_complete": true, + "query_exhausted": true, + "total_count": 1, + "total_count_available": true, + "total_count_authoritative": true, + "total_count_unavailable_reason": null }