diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f72e6c080..ffdebf84b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2768,6 +2768,8 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Hotspot marker fingerprints share one bounded tree traversal** — full/update CLI and MCP indexing compute C#, VB, F#, and MSBuild marker fingerprints together instead of walking the directory tree once per language. Each distinct marker glob retains the platform filesystem's matching behavior and is enumerated once per visited directory, while child directories are enumerated once; marker sets, budgets, truncation sentinels, and warning order remain isolated per language. The single-language API delegates to the same engine, preserving ignore rules, nested-repository/submodule boundaries, and MCP authorized-read failures. - **Lock-file dependency graphs model package relationships** — `packages.lock.json`, `package-lock.json`, and `npm-shrinkwrap.json` keep package declarations as symbols, but emit `dependency` references only for explicit parent-package to child-package entries. NuGet lock symbols and references preserve the current file, target/RID, parent package, and exact JSON property span; candidate resolution stays file-local, while file-level `deps` suppresses cross-file package-name inference. Normal index updates invalidate the prior dependency-lock extraction and reference-identity contracts, so `callers` identifies the requiring package without connecting unrelated lock files or collapsing repeated declarations to the first matching line (#4409, #4845). - **Dependency-cycle audits separate analysis from display** — CLI `deps --cycles` and MCP `deps` with `cycles=true` analyze a deterministic, path-ordered edge set up to the independent `--graph-budget` / `graphBudget` before computing and stably ranking strongly connected components. `--limit` / `limit` only paginates that ranked SCC set, and opaque cursors are bound to the filters, graph budget, and indexed graph that produced them. Machine-readable responses expose `analysis_complete`, graph edge count/budget, stable ranking mode, authoritative total-cycle status, and continuation metadata; exhausting the graph budget is reported as an explicitly incomplete analysis rather than a complete cycle audit (#4731). +- **Large dependency SCCs use bounded presentation, not bounded facts** — SCC analysis and ranking always retain authoritative component size, edge/reference totals, the largest-component summary, and evidence breakdowns independently of node presentation. CLI and MCP materialize 50 path-ordered nodes per returned component by default; human, JSON, DOT, GraphML, and JSON graph output project presentation to that sample, expose omitted-node and display-truncation metadata, support compact summaries, and require explicit `--all-cycle-nodes` / `includeAllCycleNodes` opt-in for raw expansion. The MCP cycle schema gives component node arrays a specialized maximum equal to the graph budget ceiling, so an allowed raw expansion remains schema-valid without weakening ordinary row-array bounds. Machine-readable metadata aggregates total, materialized, and omitted nodes for the current returned SCC page separately from the global largest-component summary, so later cursor pages remain authoritative. Retained-evidence summaries aggregate source language, origin, resolution state, reference kind, target kind, and suppression reason. Noise suppression classifies evidence before candidate budgeting and drops only legacy Markdown heading matches plus qualified C# calls classified by a current reference-identity contract as unresolved, ambiguous, or resolved to a target in another file. Stale or absent identity metadata fails closed to retained evidence with `resolution_state=unavailable`; the confirmed target file takes authoritative precedence even when it contains same-name overloads, and resolved partial-family groups retain only files represented by their persisted candidate set. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **Dependency-cycle cursors bind presentation evidence as well as topology** — the graph fingerprint includes each retained evidence row's source language, origin, resolution state, reference kind, target kind, suppression reason, and count in deterministic order. A metadata-only graph refresh therefore rejects an older cursor instead of mixing evidence summaries from different snapshots. MCP `format=json-graph` cycle requests use the same bounded node/edge projection as CLI graph output, and the specialized graph node/edge schema limits match the maximum cycle graph budget (#5197). - **No ORM** — Raw `Microsoft.Data.Sqlite` with parameterized queries. Keeps dependencies minimal and control explicit. - **Batch commits** — 500 records per transaction for write performance. Reduces fsync overhead. - **Set-based C# instantiation fallback** — The rank-5 unqualified `instantiate` candidate stage materializes C# type members, unique raw-name/arity families, family-scoped constructor members, and per-family explicit-constructor summaries before matching references. Unique families drive indexed constructor lookups instead of scanning every constructor or running correlated type/constructor scalar probes per candidate. Raw type names, identities, and constructor containers remain `BINARY`, family/member arity joins remain NULL-safe, partial types keep their path/start/id representative, and the final lower-rank suppression stays reference-scoped so the optimization preserves overload, implicit-default, value-type, enum, delegate, ambiguity, and unparseable-arity semantics. @@ -6945,6 +6947,8 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **hotspot marker fingerprint は上限付きtree traversalを1回共有** — full/update CLIとMCP indexingは、directory treeを言語ごとに歩かず、C#、VB、F#、MSBuildのmarker fingerprintをまとめて計算します。各directoryでは固有marker globごとにplatform filesystemのmatching挙動を保って1回ずつ列挙し、child directoryも1回だけ列挙する一方、marker集合、budget、truncation sentinel、warning順は言語別に分離します。single-language APIも同じengineへ委譲し、ignore rule、nested repository/submodule境界、MCP authorized read failureを維持します。 - **lock file の依存グラフは package 間の関係をモデル化** — `packages.lock.json`、`package-lock.json`、`npm-shrinkwrap.json` は package 宣言を symbol として保持しますが、`dependency` reference は明示された親 package → 子 package の項目だけに出力します。NuGet lock の symbol / reference は現在の file、target/RID、親 package、正確な JSON property span を保持し、candidate 解決を file 内に限定します。file 単位の `deps` は package 名による file 間推論を抑止し、通常の index update は以前の dependency-lock 抽出 contract と reference-identity contract を無効化します。そのため、`callers` は無関係な lock file を接続したり、反復宣言を最初の一致行へ畳み込んだりせず、要求元 package を特定できます(#4409、#4845)。 - **依存サイクル監査では解析と表示を分離** — CLI の `deps --cycles` と MCP `deps` の `cycles=true` は、独立した `--graph-budget` / `graphBudget` まで path 順で決定的な edge 集合を解析してから、強連結成分を安定順位付けします。`--limit` / `limit` はその SCC 順位集合をページ分割するだけで、不透明 cursor は生成時の filter、graph budget、indexed graph に結び付けます。machine-readable 応答は `analysis_complete`、graph edge 件数/予算、安定 ranking mode、authoritative な総 cycle 件数かどうか、continuation metadata を公開し、graph budget 枯渇時は完全な cycle 監査を装わず明示的な未完了解析として報告します(#4731)。 +- **大規模な依存 SCC は事実ではなく表示だけを制限する** — SCC の解析と順位付けは、node 表示とは独立して authoritative な component size、edge / reference 合計、最大 component summary、evidence breakdown を保持します。CLI / MCP は各返却 component について path 順 node を既定で 50 件だけ materialize し、human、JSON、DOT、GraphML、JSON graph の各出力をその sample へ投影して、省略 node 数と表示 truncation metadata を公開し、compact summary を提供します。raw 展開には `--all-cycle-nodes` / `includeAllCycleNodes` の明示 opt-in が必要です。MCP cycle schema は component node array 専用の最大値を graph budget 上限と同じ値にするため、通常の row array 上限を緩めずに許可済み raw 展開を schema-valid に保ちます。machine-readable metadata は current な返却 SCC page の node 合計、materialize 済み件数、省略件数を global な最大 component summary とは別に集計するため、後続 cursor page でも authoritative な件数を維持します。retained-evidence summary は source language、origin、resolution state、reference kind、target kind、suppression reason を集計します。noise 抑制は candidate budget より前に evidence を分類し、旧 Markdown 見出し一致に加え、current な reference-identity contract によって未解決、曖昧、または別 file の target へ解決済みと分類された C# 修飾 call だけを除外します。identity metadata が stale または absent の場合は `resolution_state=unavailable` の retained evidence として fail closed します。同名 overload がある場合も確認済み target の file を authoritative に優先し、解決済みpartial-family groupも保持します。grouping metadata は file 単位で、partial type family の collapse を適用していないことを明示します(#5197)。 +- **依存 cycle cursor は topology に加えて表示 evidence にも束縛する** — graph fingerprint は retained evidence 各行の source language、origin、resolution state、reference kind、target kind、suppression reason、件数を決定的な順序で含めます。そのため metadata だけが更新された graph でも古い cursor を拒否し、異なる snapshot の evidence summary を混在させません。MCP の `format=json-graph` cycle request は CLI graph 出力と同じ上限付き node / edge 投影を使い、専用 graph node / edge schema の上限も cycle graph budget の最大値と一致させます(#5197)。 - **ORMなし** — `Microsoft.Data.Sqlite`でパラメータ化クエリを直接使用。依存関係を最小限に、制御を明確に。 - **バッチコミット** — 書き込み性能のため1トランザクション500レコード。fsyncオーバーヘッドを削減。 - **C# instantiation fallback の集合処理** — rank 5 の無修飾 `instantiate` candidate 段階は、参照との照合前に C# type member、raw name / arity 単位の一意 family、family 内 constructor member、family ごとの明示 constructor summary を materialize します。一意 family から indexed constructor lookup を駆動するため、全 constructor scan や candidate ごとの相関 type / constructor scalar probe を行いません。raw type name・identity・constructor container は `BINARY`、family/member の arity join は NULL-safe のまま維持し、partial type は path/start/id 順の代表を使い、最後の lower-rank suppression も reference 単位に保つため、overload、implicit default、value type、enum、delegate、ambiguity、arity を解析できない場合の意味を変えずに高速化します。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 847567c24..96e369b8b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - Issue #5198 CODEOWNERS coverage belongs in `FileIndexerTests.cs`, `SymbolExtractorRepositoryMetadataTests.cs`, `QueryCommandRunnerFilesTests.cs`, `IndexCommandRunnerUpdateTests.cs`, `QueryCommandRunnerTests.cs`, and `McpServerToolsCallTests.cs`. Preserve the three case-sensitive Git-worktree-relative locations and the arbitrary-nested/case-variant/POSIX-literal-backslash negative cases, including scans rooted below the enclosing worktree; parser cases for full-line and inline comments, blank lines, whitespace, CRLF, rejection of unsupported escaped leading `#`, ownerless rules, user/team/email owners, malformed mentions, duplicate and overlapping patterns, invalid input, and persistable bounded diagnostics; ordered rule and owner child symbols; symbol-only capability guidance; full/scoped/delete indexing; branch-switch reconciliation; and removal from unknown-extension status diagnostics. - Issue #5194 shell-completion inventory coverage belongs in `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs`. Keep exact contextual inventories for lsp, every workspace verb, config show, diff, import, archive export, and ctags export synchronized with authoritative help and safe parser probes. Preserve positive and negative nested cases, value-taking versus boolean metadata, context-specific short aliases, mutually exclusive diff modes, exact schema-to-renderer sets for Bash/zsh/fish/PowerShell, and the available-shell generated-script execution contract. - Issue #5187 exact graph-selector coverage belongs in `QueryCommandRunnerIssue5187Tests.cs` and `McpServerIssue5187Tests.cs`. Keep one fixture with unrelated same-name methods, distinct callees, and multiple callers. Round-trip selectors emitted by `inspect` through CLI and MCP references/callers/callees/impact; preserve explicit bare-name ambiguity metadata, exact selected zero behavior after filters, stable invalid/stale/cross-database diagnostics, selector-bound cursor mismatch, structured-field discovery, command help, and all four shell completions. @@ -309,7 +310,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Outline projection validation reuses one parser/JSON-error fixture across single and multiple unknown fields, deliberately empty lists, aliases, duplicates, and mixed valid/invalid input; assert that unknown fields form one terminal usage error and do not trigger the empty-selection diagnostic. Deps JSON and json-graph byte-limit failures share one SQL graph fixture. Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. - Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. + Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters and evidence-only graph changes fail closed, MCP cycle JSON graph responses use the bounded node/edge projection, and graph-budget exhaustion marks totals as non-authoritative. Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers and non-unique resolved overload groups out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. Partial-family impact coverage must keep declarations, implementations, duplicate downstream callers, a member-to-member cycle, an unrelated same-name symbol, and a small instance-scoped family budget in one fixture family. Assert logical-root identity and physical evidence separately, prove stale reference identity does not claim logical traversal, and prove family-member truncation leaves ordinary traversal truncation unset while making count output non-authoritative (`total: null` for MCP). Fixtures that need production-equivalent partial grouping must pass `familyScopeKey` where applicable and stamp both C# hotspot-family and reference-identity readiness. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. @@ -1164,6 +1165,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - Issue #5198 の CODEOWNERS coverage は `FileIndexerTests.cs`、`SymbolExtractorRepositoryMetadataTests.cs`、`QueryCommandRunnerFilesTests.cs`、`IndexCommandRunnerUpdateTests.cs`、`QueryCommandRunnerTests.cs`、`McpServerToolsCallTests.cs` が担当します。case-sensitive な3つの Git worktree-relative location と、enclosing worktree より下を scan root にした場合を含む任意の nested file・大小文字違い file・POSIX の literal backslash file の negative case、full-line / inline comment・blank line・whitespace・CRLF・未対応である先頭 `#` の escape の拒否・ownerless rule・user/team/email owner・malformed mention・duplicate / overlapping pattern・不正 input・永続化可能な上限付き diagnostic の parser case、順序付き rule と owner child symbol、symbol-only capability guidance、full / scoped / delete indexing、branch-switch reconciliation、unknown-extension status diagnostic からの除外を維持してください。 - Issue #5194 の shell completion inventory coverage は `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` が担当します。lsp、workspace の全 verb、config show、diff、import、archive export、ctags export の正確な context 別 inventory を、authoritative help と安全な parser probe に同期してください。nested context の positive / negative case、value-taking と boolean の metadata、context 固有の short alias、diff の排他的 mode、Bash / zsh / fish / PowerShell における schema-to-renderer の完全一致、および利用可能な shell で生成 script を実行する契約を維持します。 - Issue #5187 の exact graph-selector coverage は `QueryCommandRunnerIssue5187Tests.cs` と `McpServerIssue5187Tests.cs` が担当します。無関係な同名 method、異なる callee、複数 caller を持つ 1 つの fixture を維持してください。`inspect` が出力した selector を CLI / MCP の references、callers、callees、impact へ round-trip し、bare-name ambiguity metadata の明示、filter 適用後も正確な selected zero behavior、安定した invalid / stale / cross-database diagnostic、selector に束縛された cursor mismatch、structured-field discovery、command help、4 種類すべての shell completion を保ちます。 @@ -1459,7 +1461,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" outline projection validationは、単一・複数の未知field、意図的な空list、alias、重複、valid/invalid混在を1つのparser/JSON-error fixtureで共有してください。未知fieldが1つの終端usage errorを形成し、empty-selection diagnosticを追加で発生させないことを検証してください。 depsのJSONとjson-graphのbyte-limit failureは1つのSQL graph fixtureを共有してください。 depsのJSON summary outputとjson-graph summary rejectionは1つのSQL graph fixtureを共有してください。 - dependency-cycle coverage では、graph budget が表示 limit から独立していること、page size を増やしても SCC 順位が安定すること、不透明 cursor が次の順位の component を返すこと、cursor と filter の不一致が fail-closed になること、graph-budget 枯渇時に総件数が non-authoritative と示されることを検証してください。 + dependency-cycle coverage では、graph budget が表示 limit から独立していること、page size を増やしても SCC 順位が安定すること、不透明 cursor が次の順位の component を返すこと、cursor と filter の不一致および evidence だけの graph 変更が fail-closed になること、MCP cycle JSON graph 応答が上限付き node / edge 投影を使うこと、graph-budget 枯渇時に総件数が non-authoritative と示されることを検証してください。 完全一致シンボルの dependency coverage は C# の source-candidate fixture を再利用し、絞り込み前の候補範囲より後ろにあるシンボルでも `--limit 1` query で選択できることを検証してください。repository-scale guard は10,001個の候補シンボルを構築し、Windows 以外の .NET 8 で実行し、query 部分だけを2秒以内かつ current-thread allocation 32 MiB以内に保ちます。fixture 構築は計測区間の外に置いてください。Windows では軽量な filter pushdown の機能検証を維持しつつ、全 suite が1時間の session 上限に近いため、この scale fixture は除外します。 referencesのstale SQL contract count envelopeとresult envelopeは1つのdowngraded graph fixtureを共有してください。 callersとcalleesのstale SQL contract result envelopeは1つのdowngraded graph fixtureを共有してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d412b799b..818ae4935 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2444,8 +2444,8 @@ same source location. | `--since ` | `search`, `definition`, `symbols`, `files` | Filter to files modified since this ISO 8601 timestamp. Offsetless values (e.g. `2024-01-01T00:00:00`) are treated as UTC so the same flag resolves to the same instant in every timezone; append `Z` or an explicit offset (`+09:00`) to be explicit. | | `--no-dedup` | `search` | Disable overlapping-chunk deduplication and return every raw chunk hit; useful for debugging chunk boundaries or measuring raw match density | | `--reverse` | `deps` | Reverse lookup: show files that depend ON the matched path | -| `--symbol ` / `--symbol-family ` / `--suppress-noise` | `deps` | Restrict dependency edges by an exact symbol, a symbol-name prefix, or the built-in noise profile. Exact/prefix filters and generic-symbol suppression run in SQLite before candidate ranking and `--limit`, including cycle and cross-workspace queries. `--suppress-noise` also classifies legacy Markdown heading-name matches as evidence, prioritizes retained evidence before candidate limits, and removes only that evidence so explicit Markdown links remain visible. JSON edges expose the `source_language` / `origin` / `reference_kind` / `target_kind` / `reference_count` distribution in `evidence`; `symbol_filter` reports reference before/after totals and per-reason affected/removed counts. | -| `--cycles` / `--graph-budget ` / `--cursor ` | `deps` | Compute deterministic, stably ranked dependency SCCs. `--graph-budget` independently bounds analyzed edges (default `10000`), while `--limit` pages the ranked SCCs and an opaque `next_cursor` continues the same filtered graph. JSON reports `analysis_complete`, `graph_edge_count`, `graph_edge_budget`, ranking metadata, authoritative-total status, and continuation metadata. When the graph budget is exhausted, the SCC set and total are explicitly non-authoritative; increase `--graph-budget` or narrow the graph with `--suppress-noise`, `--symbol`, `--symbol-family`, or `--path`. | +| `--symbol ` / `--symbol-family ` / `--suppress-noise` | `deps` | Restrict dependency edges by an exact symbol, a symbol-name prefix, or the built-in noise profile. Exact/prefix filters and generic-symbol suppression run in SQLite before candidate ranking and `--limit`, including cycle and cross-workspace queries. In cycle mode, `--suppress-noise` additionally removes only classified evidence: legacy Markdown heading-name matches, unresolved or ambiguous qualified C# calls, and resolved-call name matches in files that do not contain the confirmed `target_symbol_id`; this C# classification requires a current reference-identity contract and fails closed by retaining calls when the contract is stale or absent. The confirmed target file remains visible even with same-name overloads, as do explicit Markdown links. Cycle JSON evidence exposes `source_language`, `origin`, `resolution_state`, `reference_kind`, `target_kind`, `suppression_reason`, and `reference_count`; SCC `retained_evidence` summaries aggregate all six classification dimensions in matching `by_*` arrays, and `symbol_filter` reports reference before/after totals and per-reason affected/removed counts. | +| `--cycles` / `--graph-budget ` / `--cursor ` / `--all-cycle-nodes` | `deps` | Compute deterministic, stably ranked dependency SCCs. `--graph-budget` independently bounds analyzed edges (default `10000`), while `--limit` pages the ranked SCCs and an opaque `next_cursor` continues the same filtered graph. Each returned SCC materializes at most 50 path-ordered nodes by default in human, JSON, DOT, GraphML, and JSON graph output; graph edges are projected to those displayed nodes, and DOT/GraphML report authoritative omission counts plus expansion guidance on stderr. `node_count`, `nodes_returned`, `nodes_omitted_count`, `display_truncated`, `largest_component`, evidence breakdowns, and file-level grouping metadata remain available independently of `analysis_complete`; `returned_node_count`, `returned_nodes_materialized`, and `returned_nodes_omitted_count` aggregate the current SCC page separately from the global largest component. Use `--summary-only` for compact `cycle_summaries`; summary mode is incompatible with JSON graph output in both CLI and MCP. Use `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node and graph expansion; the MCP output schema permits a component's node array through the maximum graph budget. When the graph budget is exhausted, the SCC set and total are explicitly non-authoritative; increase `--graph-budget` or narrow the graph with `--suppress-noise`, `--symbol`, `--symbol-family`, or `--path`. | | `--strict-not-found` | Query commands | Return exit code `2` when a valid query produces zero rows. Without this flag, zero-result queries normally exit `0` and keep their normal empty/zero-result output; the default-format `definition --json` miss is an intentional exception that always emits `E018_QUERY_NOT_FOUND` and exits `2`. | | `--top ` | Query commands | Alias for `--limit` | | `--max-results ` | `search` | Alias for `--limit` | @@ -6098,8 +6098,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--since ` | `search`, `definition`, `symbols`, `files` | 指定タイムスタンプ以降に変更されたファイルのみ(ISO 8601)。オフセットなしの値(例: `2024-01-01T00:00:00`)は UTC として解釈されるため、どのタイムゾーンから呼び出しても同じ UTC 時点になります。明示したい場合は末尾に `Z` または `+09:00` 等のオフセットを付与してください。 | | `--no-dedup` | `search` | overlap chunk の重複排除を無効化し、全 raw chunk hit を返す。chunk 境界の debug や raw match density 計測向け | | `--reverse` | `deps` | 逆引き: 指定パスに依存しているファイルを表示 | -| `--symbol ` / `--symbol-family ` / `--suppress-noise` | `deps` | 完全一致のシンボル、シンボル名の接頭辞、または組み込み noise profile で依存 edge を絞り込む。完全一致 / 接頭辞 filter と汎用 symbol 抑制は、cycle と cross-workspace query を含め、候補の ranking と `--limit` より前に SQLite 内で適用される。`--suppress-noise` は旧 index 由来の Markdown 見出し名一致も evidence として分類し、候補上限より前に保持対象 evidence を優先したうえで、その evidence だけを除くため明示的な Markdown link は残る。JSON edge の `evidence` は `source_language` / `origin` / `reference_kind` / `target_kind` / `reference_count` の分布を公開し、`symbol_filter` は reference の before/after 合計と理由別の affected / removed 件数を返す。 | -| `--cycles` / `--graph-budget ` / `--cursor ` | `deps` | 決定的かつ安定順位付きの依存 SCC を計算する。`--graph-budget` は解析する edge 数を独立して制限し(既定値 `10000`)、`--limit` は順位付け済み SCC をページ分割し、不透明な `next_cursor` で同じ filter 済み graph の続きを取得する。JSON は `analysis_complete`、`graph_edge_count`、`graph_edge_budget`、ranking metadata、総件数が authoritative かどうか、continuation metadata を返す。graph budget 枯渇時は SCC 集合と総件数が non-authoritative であることを明示するため、`--graph-budget` を増やすか、`--suppress-noise`、`--symbol`、`--symbol-family`、`--path` で graph を絞り込む。 | +| `--symbol ` / `--symbol-family ` / `--suppress-noise` | `deps` | 完全一致のシンボル、シンボル名の接頭辞、または組み込み noise profile で依存 edge を絞り込む。完全一致 / 接頭辞 filter と汎用 symbol 抑制は、cycle と cross-workspace query を含め、候補の ranking と `--limit` より前に SQLite 内で適用される。cycle mode では、`--suppress-noise` が追加で除外するのは、旧 index 由来の Markdown 見出し名一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` を含まない file に解決済み C# 修飾 call が作る同名一致だけである。この C# 分類には current な reference-identity contract が必要で、contract が stale または absent の場合は call を保持して fail closed する。同名 overload があっても確認済み target の file と明示的な Markdown link は残る。cycle JSON evidence は `source_language`、`origin`、`resolution_state`、`reference_kind`、`target_kind`、`suppression_reason`、`reference_count` を公開し、SCC の `retained_evidence` summary は 6 種類すべての分類 dimension を対応する `by_*` array に集計する。`symbol_filter` は reference の before/after 合計と理由別の affected / removed 件数を返す。 | +| `--cycles` / `--graph-budget ` / `--cursor ` / `--all-cycle-nodes` | `deps` | 決定的かつ安定順位付きの依存 SCC を計算する。`--graph-budget` は解析する edge 数を独立して制限し(既定値 `10000`)、`--limit` は順位付け済み SCC をページ分割し、不透明な `next_cursor` で同じ filter 済み graph の続きを取得する。各 SCC の path 順 node は human、JSON、DOT、GraphML、JSON graph の各出力で既定で最大 50 件だけ materialize し、graph edge はその表示 node へ投影する。DOT / GraphML は authoritative な省略件数と展開 guidance を stderr に報告する。`node_count`、`nodes_returned`、`nodes_omitted_count`、`display_truncated`、`largest_component`、evidence breakdown、file 単位の grouping metadata は `analysis_complete` と独立して返し、`returned_node_count`、`returned_nodes_materialized`、`returned_nodes_omitted_count` は current な SCC page を global な最大 component とは別に集計する。簡潔な `cycle_summaries` には `--summary-only` を使うが、summary mode は CLI / MCP とも JSON graph 出力とは併用できない。明示的な raw node / graph 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使い、MCP output schema は最大 graph budget までの component node array を許可する。graph budget 枯渇時は SCC 集合と総件数が non-authoritative であることを明示するため、`--graph-budget` を増やすか、`--suppress-noise`、`--symbol`、`--symbol-family`、`--path` で graph を絞り込む。 | | `--workspace-db ` | `deps` | file dependency query に別の CodeIndex DB を追加する。最大 7 個の distinct な追加 DB(`--db` を含め合計 8 個)まで繰り返し指定でき、JSON edge には同じ相対パスを区別できるよう `source_db` / `target_db` が含まれる。 | | `--strict-not-found` | クエリ系 | 有効な query の結果が 0 件なら終了コード `2` を返す。この flag がない場合、0 件の query は通常、既存の empty / zero-result output を維持して終了コード `0` を返す。ただし既定 format の `definition --json` 未検出は意図的な例外で、常に `E018_QUERY_NOT_FOUND` と終了コード `2` を返す。 | | `--top ` | クエリ系 | `--limit` のエイリアス | diff --git a/changelog.d/unreleased/5197.changed.md b/changelog.d/unreleased/5197.changed.md new file mode 100644 index 000000000..0e6226cfa --- /dev/null +++ b/changelog.d/unreleased/5197.changed.md @@ -0,0 +1,38 @@ +--- +category: changed +issues: + - 5197 +affected: + - src/CodeIndex/Cli/CliContractManifest.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs + - src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbReader.DependencyCycleCandidateSql.cs + - src/CodeIndex/Database/DbReader.DependencyCycleEvidenceSql.cs + - src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs + - src/CodeIndex/Database/DbReader.DependencyProjection.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs + - src/CodeIndex/Mcp/McpToolOutputSchemas.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs + - tests/CodeIndex.Tests/McpServerIssue5197Tests.cs + - tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs + - tests/CodeIndex.Tests/golden/deps-cycles-summary.json + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, all evidence dimensions, largest-component details, current-page omission totals, and file-level grouping metadata while materializing only 50 nodes per component by default across human, JSON, DOT, GraphML, and JSON graph output. Compact summaries and schema-valid explicit raw expansion are available, MCP cycle JSON graphs use the bounded projection, evidence metadata is bound into continuation cursors, and C# noise suppression uses only current reference-identity metadata to remove unresolved, ambiguous, or resolved same-name non-target candidates while preserving confirmed targets, restricting resolved groups to their persisted candidate files, and failing closed for stale or absent identity contracts. + +## 日本語 + +- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、すべての evidence dimension、最大 component の詳細、current page の省略合計、file 単位の grouping metadata を保持しつつ、human、JSON、DOT、GraphML、JSON graph の各出力で component ごとの node materialization を既定で 50 件に制限します。compact summary と schema-valid な明示的 raw 展開を利用でき、MCP cycle JSON graph は上限付き投影を使い、evidence metadata は continuation cursor に束縛されます。C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済み call の同名非 target candidate を除外し、確認済み target を保持し、resolved group を永続化済み candidate file に限定するとともに stale / absent identity contract では fail closed します。 diff --git a/src/CodeIndex/Cli/CliContractManifest.cs b/src/CodeIndex/Cli/CliContractManifest.cs index 7bd35639c..60275dfe1 100644 --- a/src/CodeIndex/Cli/CliContractManifest.cs +++ b/src/CodeIndex/Cli/CliContractManifest.cs @@ -76,6 +76,7 @@ internal static class CliContractManifest new("references", "references.json"), new("impact", "impact.json"), new("excerpt", "excerpt.json"), + new("deps-cycles-summary", "deps-cycles-summary.json"), new("suggestions-compact", "suggestions-compact.json"), ]; diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 39a9fcaf9..7b25d7bad 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -582,7 +582,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--include-telemetry", Description = "Diff: include volatile index-run and FTS maintenance telemetry in identity", PrimaryCommands = Set("diff") }, new() { Name = "--cycles", Description = "Deps: return deterministically ranked dependency SCCs with stable pagination", PrimaryCommands = Set(DependencyCycleCommands) }, new() { Name = "--graph-budget", ValuePlaceholder = "", Description = $"Deps cycles: maximum graph edges analyzed for SCC completeness (default: {QueryCommandRunner.DefaultDependencyCycleGraphBudget})", PrimaryCommands = Set(DependencyCycleCommands) }, - new() { Name = "--suppress-noise", Description = "Deps: suppress generic framework symbols and legacy Markdown heading-name fanout while preserving explicit links", PrimaryCommands = Set("deps") }, + new() { Name = "--all-cycle-nodes", Description = $"Deps cycles: expand every SCC path instead of the default {QueryCommandRunner.DefaultDependencyCycleNodeLimit}-node sample", PrimaryCommands = Set(DependencyCycleCommands) }, + new() { Name = "--suppress-noise", Description = "Deps: suppress generic framework symbols, legacy Markdown heading fanout, and non-authoritative qualified C# calls while preserving resolved evidence", PrimaryCommands = Set("deps") }, new() { Name = "--symbol", ValuePlaceholder = "", Description = "Deps: keep only edges with an exact sampled symbol name", PrimaryCommands = Set("deps") }, new() { Name = "--symbol-family", ValuePlaceholder = "", Description = "Deps: keep only edges with a sampled symbol prefix/family", PrimaryCommands = Set("deps") }, new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", PrimaryCommands = Set(LanguagesFilterCommands) }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs index cde96e14c..6da9a9f5b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs @@ -62,6 +62,9 @@ private bool TryParseResultOption(string normalizedArg, string currentArg, strin case "--cycles": dependencyCycles = true; break; + case "--all-cycle-nodes": + includeAllDependencyCycleNodes = true; + break; case "--suppress-noise": dependencySuppressNoise = true; break; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 13b83dffe..14963911f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -151,6 +151,7 @@ private sealed partial class QueryArgumentParser private bool mapSummaryOnly; private bool dependencyCycles; private int dependencyCycleGraphBudget = DefaultDependencyCycleGraphBudget; + private bool includeAllDependencyCycleNodes; private bool dependencySuppressNoise; private List dependencySymbols = []; private List dependencySymbolFamilies = []; @@ -537,6 +538,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r MapSummaryOnly = mapSummaryOnly, DependencyCycles = dependencyCycles, DependencyCycleGraphBudget = dependencyCycleGraphBudget, + IncludeAllDependencyCycleNodes = includeAllDependencyCycleNodes, DependencySuppressNoise = dependencySuppressNoise, DependencySymbols = dependencySymbols, DependencySymbolFamilies = dependencySymbolFamilies, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 3f503707a..03308b0bd 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -618,6 +618,14 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C "Use `cdidx deps --cycles --graph-budget `."); return CommandExitCodes.UsageError; } + if (options.IncludeAllDependencyCycleNodes && !options.DependencyCycles) + { + WriteUsageError( + "deps --all-cycle-nodes requires --cycles.", + GetUsageLineOrThrow("deps"), + "Use `cdidx deps --cycles --json --all-cycle-nodes`."); + return CommandExitCodes.UsageError; + } var reverse = cmdArgs.Any(static arg => arg == "--reverse"); var cycleCursorBaseFingerprint = BuildDependencyCycleCursorFingerprint(options, reverse); @@ -717,7 +725,10 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C zeroSymbolFilter, payload => { - AddDependencyCycleAnalysisJsonFields(payload, zeroAnalysis); + AddDependencyCycleAnalysisJsonFields( + payload, + zeroAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes); AddDependencyGraphAvailabilityJsonFields(payload, reader._hasReferencesTable); }); return writeExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : writeExitCode; @@ -726,10 +737,16 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C { var payload = new JsonObject { ["count"] = 0 }; if (options.SummaryOnly) + { payload["summary_only"] = true; + payload["cycle_summaries"] = new JsonArray(); + } else payload["cycles"] = new JsonArray(); - AddDependencyCycleAnalysisJsonFields(payload, zeroAnalysis); + AddDependencyCycleAnalysisJsonFields( + payload, + zeroAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes); AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, zeroSqlGraphSignal, zeroSymbolFilter); AddDependencyGraphAvailabilityJsonFields(payload, reader._hasReferencesTable); AddFreshnessHint(payload, reader); @@ -869,11 +886,17 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C { var payload = new JsonObject { ["count"] = 0 }; if (options.SummaryOnly) + { payload["summary_only"] = true; + payload["cycle_summaries"] = new JsonArray(); + } else payload["cycles"] = new JsonArray(); if (dependencyCycleAnalysis != null) - AddDependencyCycleAnalysisJsonFields(payload, dependencyCycleAnalysis); + AddDependencyCycleAnalysisJsonFields( + payload, + dependencyCycleAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes); AddDependencySchemaJsonFields(payload, reader, options, jsonOptions, sqlGraphSignal, symbolFilter.Summary); AddFreshnessHint(payload, reader); WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: outputEdges.Count, cycleCount: 0, machineReadable: machineReadable); @@ -884,7 +907,9 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C { CommandErrorWriter.WriteStderr(BuildZeroResultLine("No dependency cycles found", options)); if (dependencyCycleAnalysis is { Truncated: true }) - CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning(dependencyCycleAnalysis)); + CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning( + dependencyCycleAnalysis, + options.IncludeAllDependencyCycleNodes)); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); } return ZeroResultExitCode(options); @@ -892,18 +917,48 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C if (depsFormat is OutputFormatDot or OutputFormatGraphMl or OutputFormatJsonGraph) { - WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: outputEdges.Count, cycleCount: cycles.Count, machineReadable: machineReadable); + var graphEdges = outputEdges; + IReadOnlyList? graphNodes = null; + if (dependencyCycleAnalysis != null) + { + var graphProjection = BuildDependencyCycleGraphProjection( + outputEdges, + dependencyCycleAnalysis.Components, + options.IncludeAllDependencyCycleNodes); + graphEdges = graphProjection.Edges; + graphNodes = graphProjection.Nodes; + } + WriteGraphLiveness("deps", "write_output", options, depsFormat, rows: graphEdges.Count, cycleCount: cycles.Count, machineReadable: machineReadable); var writeExitCode = WriteDependencyGraph( - outputEdges, + graphEdges, depsFormat, jsonOptions, reader, options, sqlGraphSignal, symbolFilter.Summary, - dependencyCycleAnalysis == null ? null : payload => AddDependencyCycleAnalysisJsonFields(payload, dependencyCycleAnalysis)); - if ((depsFormat is OutputFormatDot or OutputFormatGraphMl) && dependencyCycleAnalysis is { Truncated: true }) - CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning(dependencyCycleAnalysis)); + dependencyCycleAnalysis == null + ? null + : payload => AddDependencyCycleAnalysisJsonFields( + payload, + dependencyCycleAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes), + graphNodes); + if (depsFormat is OutputFormatDot or OutputFormatGraphMl) + { + if (dependencyCycleAnalysis != null + && !options.IncludeAllDependencyCycleNodes + && HasTruncatedReturnedDependencyCycleNodeDisplay(dependencyCycleAnalysis)) + { + CommandErrorWriter.WriteStderr(BuildDependencyCycleGraphDisplayWarning(dependencyCycleAnalysis)); + } + if (dependencyCycleAnalysis is { Truncated: true }) + { + CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning( + dependencyCycleAnalysis, + options.IncludeAllDependencyCycleNodes)); + } + } return writeExitCode; } @@ -917,12 +972,22 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C payload["summary_only"] = true; if (options.DependencyCycles) { - if (!options.SummaryOnly) - payload["cycles"] = dependencyCycleAnalysis == null - ? new JsonArray() - : BuildDependencyCyclesJson(dependencyCycleAnalysis.Components, dependencyCycleAnalysis.PageOffset); if (dependencyCycleAnalysis != null) - AddDependencyCycleAnalysisJsonFields(payload, dependencyCycleAnalysis); + { + payload[options.SummaryOnly ? "cycle_summaries" : "cycles"] = BuildDependencyCyclesJson( + dependencyCycleAnalysis.Components, + dependencyCycleAnalysis.PageOffset, + options.IncludeAllDependencyCycleNodes); + } + else if (!options.SummaryOnly) + { + payload["cycles"] = new JsonArray(); + } + if (dependencyCycleAnalysis != null) + AddDependencyCycleAnalysisJsonFields( + payload, + dependencyCycleAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes); } else if (!options.SummaryOnly) payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, CliJsonSerializerContextFactory.Create(jsonOptions).ListFileDependencyResult); @@ -936,13 +1001,15 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C if (options.DependencyCycles) { foreach (var cycle in cycles) - Console.WriteLine(string.Join(" -> ", cycle.Concat([cycle[0]]))); + Console.WriteLine(BuildDependencyCycleHumanLine(cycle, options.IncludeAllDependencyCycleNodes)); var truncationNote = dependencyCycleAnalysis is { Truncated: true } ? $"; {BuildDependencyCycleTruncationSummary(dependencyCycleAnalysis)}" : string.Empty; CommandErrorWriter.WriteStderr($"({cycles.Count} dependency cycles{truncationNote})"); if (dependencyCycleAnalysis is { Truncated: true }) - CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning(dependencyCycleAnalysis)); + CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning( + dependencyCycleAnalysis, + options.IncludeAllDependencyCycleNodes)); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); return CommandExitCodes.Success; } @@ -959,6 +1026,19 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C }, cancellationToken: cancellationToken); } + private static string BuildDependencyCycleHumanLine( + IReadOnlyList cycle, + bool includeAllNodes) + { + var materializationLimit = includeAllNodes ? cycle.Count : DefaultDependencyCycleNodeLimit; + var returnedNodes = cycle.Take(materializationLimit).ToList(); + if (returnedNodes.Count == cycle.Count) + return string.Join(" -> ", returnedNodes.Concat([returnedNodes[0]])); + + return string.Join(" -> ", returnedNodes) + + $" -> ... ({cycle.Count - returnedNodes.Count} nodes omitted; rerun with --all-cycle-nodes)"; + } + private static void WriteDependencyCycleCursorMismatchError() => WriteUsageError( "deps --cursor does not match the current dependency-cycle filters, graph budget, or indexed graph.", @@ -1146,6 +1226,13 @@ private static List FindRankedDependencyCycles( nodeToComponent[node] = componentIndex; var internalEdgeCounts = new int[cycleNodes.Count]; var referenceCounts = new long[cycleNodes.Count]; + var classifiedReferenceCounts = new long[cycleNodes.Count]; + var evidenceByLanguage = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceByOrigin = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceByResolutionState = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceByReferenceKind = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceByTargetKind = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceBySuppressionReason = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); foreach (var edge in edges) { cancellationToken.ThrowIfCancellationRequested(); @@ -1155,13 +1242,31 @@ private static List FindRankedDependencyCycles( continue; internalEdgeCounts[sourceComponent]++; referenceCounts[sourceComponent] += edge.ReferenceCount; + foreach (var evidence in edge.Evidence ?? []) + { + classifiedReferenceCounts[sourceComponent] += evidence.ReferenceCount; + AddDependencyCycleEvidenceCount(evidenceByLanguage[sourceComponent], evidence.SourceLanguage, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceByOrigin[sourceComponent], evidence.Origin, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceByResolutionState[sourceComponent], evidence.ResolutionState, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceByReferenceKind[sourceComponent], evidence.ReferenceKind, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceByTargetKind[sourceComponent], evidence.TargetKind, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceBySuppressionReason[sourceComponent], evidence.SuppressionReason, evidence.ReferenceCount); + } } return cycleNodes .Select((component, componentIndex) => new DependencyCycleComponent( component, internalEdgeCounts[componentIndex], - referenceCounts[componentIndex])) + referenceCounts[componentIndex], + new DependencyCycleEvidenceBreakdown( + classifiedReferenceCounts[componentIndex], + ToDependencyCycleEvidenceCounts(evidenceByLanguage[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByOrigin[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByResolutionState[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByReferenceKind[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByTargetKind[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceBySuppressionReason[componentIndex])))) .OrderByDescending(static component => component.ReferenceCount) .ThenByDescending(static component => component.InternalEdgeCount) .ThenByDescending(static component => component.Nodes.Count) @@ -1169,6 +1274,22 @@ private static List FindRankedDependencyCycles( .ToList(); } + private static Dictionary[] CreateDependencyCycleEvidenceMaps(int count) + => Enumerable.Range(0, count) + .Select(static _ => new Dictionary(StringComparer.Ordinal)) + .ToArray(); + + private static void AddDependencyCycleEvidenceCount(Dictionary counts, string? value, int referenceCount) + { + var key = string.IsNullOrEmpty(value) ? "unavailable" : value; + counts[key] = counts.GetValueOrDefault(key) + referenceCount; + } + + private static List ToDependencyCycleEvidenceCounts(Dictionary counts) + => counts.OrderBy(static pair => pair.Key, StringComparer.Ordinal) + .Select(static pair => new DependencyCycleEvidenceCount(pair.Key, pair.Value)) + .ToList(); + private static List FilterEdgesToComponents( IReadOnlyList edges, IReadOnlyList components) @@ -1186,26 +1307,98 @@ private static List FilterEdgesToComponents( .ToList(); } + internal sealed record DependencyCycleGraphProjection( + List Edges, + IReadOnlyList Nodes); + + internal static DependencyCycleGraphProjection BuildDependencyCycleGraphProjection( + IReadOnlyList edges, + IReadOnlyList components, + bool includeAllNodes) + { + var nodes = components + .SelectMany(component => includeAllNodes + ? component.Nodes + : component.Nodes.Take(DefaultDependencyCycleNodeLimit)) + .Distinct(StringComparer.Ordinal) + .ToList(); + var nodeSet = nodes.ToHashSet(StringComparer.Ordinal); + var projectedEdges = edges + .Where(edge => nodeSet.Contains(edge.SourcePath) && nodeSet.Contains(edge.TargetPath)) + .ToList(); + return new DependencyCycleGraphProjection(projectedEdges, nodes); + } + internal static JsonArray BuildDependencyCyclesJson( IReadOnlyList components, - int pageOffset) + int pageOffset, + bool includeAllNodes = false, + bool mcpArguments = false) { var array = new JsonArray(); for (var i = 0; i < components.Count; i++) + array.Add(BuildDependencyCycleComponentJson(components[i], pageOffset + i + 1, includeAllNodes, mcpArguments)); + return array; + } + + private static JsonObject BuildDependencyCycleComponentJson( + DependencyCycleComponent component, + int rank, + bool includeAllNodes, + bool mcpArguments) + { + var materializationLimit = includeAllNodes ? component.Nodes.Count : DefaultDependencyCycleNodeLimit; + var returnedNodes = component.Nodes.Take(materializationLimit).ToList(); + var nodesTruncated = returnedNodes.Count < component.Nodes.Count; + var payload = new JsonObject { - var component = components[i]; - array.Add(new JsonObject - { - ["rank"] = pageOffset + i + 1, - ["length"] = component.Nodes.Count, - ["internal_edge_count"] = component.InternalEdgeCount, - ["reference_count"] = component.ReferenceCount, - ["nodes"] = new JsonArray(component.Nodes.Select(node => JsonValue.Create(node)).ToArray()) - }); + ["rank"] = rank, + ["length"] = component.Nodes.Count, + ["node_count"] = component.Nodes.Count, + ["internal_edge_count"] = component.InternalEdgeCount, + ["reference_count"] = component.ReferenceCount, + ["nodes"] = new JsonArray(returnedNodes.Select(node => JsonValue.Create(node)).ToArray()), + ["nodes_returned"] = returnedNodes.Count, + ["nodes_truncated"] = nodesTruncated, + ["nodes_omitted_count"] = component.Nodes.Count - returnedNodes.Count, + ["node_limit"] = includeAllNodes ? null : DefaultDependencyCycleNodeLimit, + ["retained_evidence"] = BuildDependencyCycleEvidenceBreakdownJson(component), + }; + if (nodesTruncated) + { + payload["node_expansion"] = mcpArguments + ? "Set includeAllCycleNodes=true to return every file path in this component." + : "Rerun with --all-cycle-nodes to return every file path in this component."; } - return array; + return payload; + } + + private static JsonObject BuildDependencyCycleEvidenceBreakdownJson(DependencyCycleComponent component) + { + var evidence = component.EvidenceBreakdown ?? DependencyCycleEvidenceBreakdown.Empty; + return new JsonObject + { + ["retained_reference_count"] = component.ReferenceCount, + ["classified_reference_count"] = evidence.ClassifiedReferenceCount, + ["classification_complete"] = evidence.ClassifiedReferenceCount == component.ReferenceCount, + ["by_source_language"] = BuildDependencyCycleEvidenceCountsJson(evidence.BySourceLanguage, "source_language"), + ["by_origin"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByOrigin, "origin"), + ["by_resolution_state"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByResolutionState, "resolution_state"), + ["by_reference_kind"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByReferenceKind, "reference_kind"), + ["by_target_kind"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByTargetKind, "target_kind"), + ["by_suppression_reason"] = BuildDependencyCycleEvidenceCountsJson(evidence.BySuppressionReason, "suppression_reason"), + }; } + private static JsonArray BuildDependencyCycleEvidenceCountsJson( + IReadOnlyList counts, + string key) + => new(counts.Select(count => (JsonNode?)new JsonObject + { + [key] = count.Value, + ["reference_count"] = count.ReferenceCount, + }).ToArray()); + internal static JsonArray BuildDependencyCyclesJson(IReadOnlyList> cycles) => BuildDependencyCyclesJson( cycles.Select(static cycle => new DependencyCycleComponent(cycle, 0, 0)).ToList(), @@ -1214,7 +1407,22 @@ internal static JsonArray BuildDependencyCyclesJson(IReadOnlyList> internal sealed record DependencyCycleComponent( List Nodes, int InternalEdgeCount, - long ReferenceCount); + long ReferenceCount, + DependencyCycleEvidenceBreakdown? EvidenceBreakdown = null); + + internal sealed record DependencyCycleEvidenceCount(string Value, long ReferenceCount); + + internal sealed record DependencyCycleEvidenceBreakdown( + long ClassifiedReferenceCount, + IReadOnlyList BySourceLanguage, + IReadOnlyList ByOrigin, + IReadOnlyList ByResolutionState, + IReadOnlyList ByReferenceKind, + IReadOnlyList ByTargetKind, + IReadOnlyList BySuppressionReason) + { + internal static readonly DependencyCycleEvidenceBreakdown Empty = new(0, [], [], [], [], [], []); + } internal sealed record DependencyCycleAnalysis( List Edges, @@ -1235,6 +1443,8 @@ internal sealed record DependencyCycleAnalysis( string RankingMode) { public List> Cycles => Components.Select(static component => component.Nodes).ToList(); + public DependencyCycleComponent? LargestComponent { get; init; } + public int LargestComponentRank { get; init; } } internal static DependencyCycleAnalysis AnalyzeDependencyCycles( @@ -1268,6 +1478,13 @@ internal static DependencyCycleAnalysis AnalyzeDependencyCycles( ? FormatDependencyCycleCursor(new DependencyCycleCursor(nextOffset, cursorFingerprint)) : null; + var largestComponentEntry = allComponents + .Select((component, index) => (Component: component, Rank: index + 1)) + .OrderByDescending(static entry => entry.Component.Nodes.Count) + .ThenByDescending(static entry => entry.Component.InternalEdgeCount) + .ThenByDescending(static entry => entry.Component.ReferenceCount) + .ThenBy(static entry => entry.Component.Nodes[0], StringComparer.Ordinal) + .FirstOrDefault(); return new DependencyCycleAnalysis( outputEdges, components, @@ -1284,10 +1501,18 @@ internal static DependencyCycleAnalysis AnalyzeDependencyCycles( hasMore, nextCursor, DependencyCycleDetectionMode, - DependencyCycleRankingMode); + DependencyCycleRankingMode) + { + LargestComponent = largestComponentEntry.Component, + LargestComponentRank = largestComponentEntry.Rank, + }; } - internal static void AddDependencyCycleAnalysisJsonFields(JsonObject payload, DependencyCycleAnalysis analysis, bool mcpArguments = false) + internal static void AddDependencyCycleAnalysisJsonFields( + JsonObject payload, + DependencyCycleAnalysis analysis, + bool mcpArguments = false, + bool includeAllNodes = false) { payload["truncated"] = analysis.Truncated; payload["termination_reason"] = analysis.TerminationReason; @@ -1310,7 +1535,52 @@ internal static void AddDependencyCycleAnalysisJsonFields(JsonObject payload, De payload["returned_count"] = analysis.Components.Count; payload["has_more"] = analysis.HasMore; payload["next_cursor"] = analysis.NextCursor; - payload["next_step_flags"] = BuildDependencyCycleNextStepFlagsJson(analysis, mcpArguments); + var returnedNodeCount = analysis.Components.Sum(static component => component.Nodes.Count); + var returnedNodesMaterialized = includeAllNodes + ? returnedNodeCount + : analysis.Components.Sum(static component => Math.Min( + component.Nodes.Count, + DefaultDependencyCycleNodeLimit)); + payload["returned_node_count"] = returnedNodeCount; + payload["returned_nodes_materialized"] = returnedNodesMaterialized; + payload["returned_nodes_omitted_count"] = returnedNodeCount - returnedNodesMaterialized; + var displayTruncated = !includeAllNodes && HasTruncatedDependencyCycleNodeDisplay(analysis); + payload["display_truncated"] = displayTruncated; + payload["display_truncation_reason"] = displayTruncated ? "component_node_limit" : null; + payload["node_materialization_mode"] = includeAllNodes ? "complete" : "bounded_sample"; + payload["node_materialization_limit"] = includeAllNodes ? null : DefaultDependencyCycleNodeLimit; + payload["cycle_grouping_mode"] = "file"; + payload["cycle_grouping_applied"] = false; + payload["cycle_grouping_reason"] = "file_level_scc"; + if (analysis.LargestComponent != null) + { + payload["largest_component"] = BuildDependencyCycleComponentJson( + analysis.LargestComponent, + analysis.LargestComponentRank, + includeAllNodes, + mcpArguments); + } + payload["next_step_flags"] = BuildDependencyCycleNextStepFlagsJson(analysis, mcpArguments, includeAllNodes); + } + + private static bool HasTruncatedDependencyCycleNodeDisplay(DependencyCycleAnalysis analysis) + => HasTruncatedReturnedDependencyCycleNodeDisplay(analysis) + || analysis.LargestComponent is { Nodes.Count: > DefaultDependencyCycleNodeLimit }; + + private static bool HasTruncatedReturnedDependencyCycleNodeDisplay(DependencyCycleAnalysis analysis) + => analysis.Components.Any(static component => component.Nodes.Count > DefaultDependencyCycleNodeLimit); + + private static string BuildDependencyCycleGraphDisplayWarning(DependencyCycleAnalysis analysis) + { + var truncatedComponents = analysis.Components + .Where(static component => component.Nodes.Count > DefaultDependencyCycleNodeLimit) + .ToList(); + var omittedNodeCount = truncatedComponents.Sum( + static component => component.Nodes.Count - DefaultDependencyCycleNodeLimit); + var componentLabel = truncatedComponents.Count == 1 ? "component" : "components"; + return $"Warning: dependency cycle graph presentation is limited to {DefaultDependencyCycleNodeLimit} nodes per component; " + + $"{omittedNodeCount} nodes are omitted across {truncatedComponents.Count} returned {componentLabel}. " + + "SCC analysis metrics remain unchanged; rerun with --all-cycle-nodes for complete graph output."; } private static string BuildDependencyCycleTruncationSummary(DependencyCycleAnalysis analysis) @@ -1318,9 +1588,11 @@ private static string BuildDependencyCycleTruncationSummary(DependencyCycleAnaly ? $"page complete: showing ranked cycles {analysis.PageOffset + 1}-{analysis.PageOffset + analysis.Components.Count}" : $"partial analysis: graph edge budget reached after {analysis.GraphEdgeCount} edges"; - private static string BuildDependencyCycleTruncationWarning(DependencyCycleAnalysis analysis) + private static string BuildDependencyCycleTruncationWarning( + DependencyCycleAnalysis analysis, + bool includeAllNodes) { - var nextSteps = BuildDependencyCycleNextStepFlags(analysis, mcpArguments: false); + var nextSteps = BuildDependencyCycleNextStepFlags(analysis, mcpArguments: false, includeAllNodes); var nextStepsText = nextSteps.Count == 0 ? string.Empty : $" Next steps: {string.Join(", ", nextSteps)}."; @@ -1348,16 +1620,23 @@ private static string BuildDependencyCycleResultNote(string? truncatedReason) private static JsonArray BuildDependencyCycleNextStepFlagsJson( DependencyCycleAnalysis analysis, - bool mcpArguments) - => new(BuildDependencyCycleNextStepFlags(analysis, mcpArguments) + bool mcpArguments, + bool includeAllNodes) + => new(BuildDependencyCycleNextStepFlags(analysis, mcpArguments, includeAllNodes) .Select(flag => JsonValue.Create(flag)) .ToArray()); private static List BuildDependencyCycleNextStepFlags( DependencyCycleAnalysis analysis, - bool mcpArguments) + bool mcpArguments, + bool includeAllNodes) { var flags = new List(); + if (!includeAllNodes && HasTruncatedDependencyCycleNodeDisplay(analysis)) + { + flags.Add(mcpArguments ? "includeAllCycleNodes=true" : "--all-cycle-nodes"); + flags.Add(mcpArguments ? "path=[\"\"]" : "--path "); + } if (analysis.HasMore && analysis.NextCursor != null) flags.Add(mcpArguments ? $"cursor={analysis.NextCursor}" : $"--cursor {analysis.NextCursor}"); if (analysis.TruncatedReason == "graph_edge_budget") @@ -1423,6 +1702,26 @@ internal static string BuildDependencyCycleGraphFingerprint( AppendDependencyCycleGraphHashValue(hash, edge.TargetDb); AppendDependencyCycleGraphHashValue(hash, edge.TargetPath); AppendDependencyCycleGraphHashValue(hash, edge.ReferenceCount.ToString(CultureInfo.InvariantCulture)); + var evidence = (edge.Evidence ?? []) + .OrderBy(static item => item.SourceLanguage, StringComparer.Ordinal) + .ThenBy(static item => item.Origin, StringComparer.Ordinal) + .ThenBy(static item => item.ResolutionState, StringComparer.Ordinal) + .ThenBy(static item => item.ReferenceKind, StringComparer.Ordinal) + .ThenBy(static item => item.TargetKind, StringComparer.Ordinal) + .ThenBy(static item => item.SuppressionReason, StringComparer.Ordinal) + .ThenBy(static item => item.ReferenceCount) + .ToArray(); + AppendDependencyCycleGraphHashValue(hash, evidence.Length.ToString(CultureInfo.InvariantCulture)); + foreach (var item in evidence) + { + AppendDependencyCycleGraphHashValue(hash, item.SourceLanguage); + AppendDependencyCycleGraphHashValue(hash, item.Origin); + AppendDependencyCycleGraphHashValue(hash, item.ResolutionState); + AppendDependencyCycleGraphHashValue(hash, item.ReferenceKind); + AppendDependencyCycleGraphHashValue(hash, item.TargetKind); + AppendDependencyCycleGraphHashValue(hash, item.SuppressionReason); + AppendDependencyCycleGraphHashValue(hash, item.ReferenceCount.ToString(CultureInfo.InvariantCulture)); + } } var digest = hash.GetHashAndReset(); @@ -1495,7 +1794,7 @@ internal static bool TryParseDependencyCycleCursor(string value, out DependencyC } } - private static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOnlyList edges, QueryCommandOptions options) + internal static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOnlyList edges, QueryCommandOptions options) { var applied = options.DependencySuppressNoise || options.DependencySymbols.Count > 0 || options.DependencySymbolFamilies.Count > 0; if (!applied) @@ -1523,9 +1822,7 @@ private static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOn var symbolsAfter = 0; long referencesBefore = 0; long referencesAfter = 0; - var headingEdgesAffected = 0; - var headingEdgesRemoved = 0; - long headingReferencesRemoved = 0; + var suppressionReasons = new Dictionary(StringComparer.Ordinal); foreach (var edge in edges) { referencesBefore += edge.ReferenceCount; @@ -1536,19 +1833,27 @@ private static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOn var referenceCount = edge.ReferenceCount; if (options.DependencySuppressNoise && edgeEvidence.Count > 0) { + var removedEvidenceByReason = edgeEvidence + .Select(static evidence => (Evidence: evidence, Reason: GetDependencySuppressionReason(evidence))) + .Where(static item => item.Reason != null) + .GroupBy(static item => item.Reason!, StringComparer.Ordinal) + .ToList(); keptEvidence = edgeEvidence - .Where(static evidence => evidence.Origin != "markdown_heading_name_match") + .Where(static evidence => GetDependencySuppressionReason(evidence) == null) .ToList(); - var removedReferenceCount = edgeEvidence - .Where(static evidence => evidence.Origin == "markdown_heading_name_match") - .Sum(static evidence => (long)evidence.ReferenceCount); + var removedReferenceCount = removedEvidenceByReason + .Sum(static group => group.Sum(static item => (long)item.Evidence.ReferenceCount)); if (removedReferenceCount > 0) { - headingEdgesAffected++; - headingReferencesRemoved += removedReferenceCount; referenceCount = (int)Math.Max(0L, edge.ReferenceCount - removedReferenceCount); - if (referenceCount == 0) - headingEdgesRemoved++; + foreach (var group in removedEvidenceByReason) + { + var previous = suppressionReasons.GetValueOrDefault(group.Key); + suppressionReasons[group.Key] = ( + previous.EdgesAffected + 1, + previous.EdgesRemoved + (referenceCount == 0 ? 1 : 0), + previous.ReferencesRemoved + group.Sum(static item => (long)item.Evidence.ReferenceCount)); + } } } @@ -1572,16 +1877,14 @@ private static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOn keptEvidence)); } - IReadOnlyList suppressionReasons = - headingReferencesRemoved > 0 - ? [ - new DependencySuppressionReasonSummary( - Reason: "markdown_heading_name_match", - EdgesAffected: headingEdgesAffected, - EdgesRemoved: headingEdgesRemoved, - ReferencesRemoved: headingReferencesRemoved), - ] - : []; + var suppressionReasonSummaries = suppressionReasons + .OrderBy(static pair => pair.Key, StringComparer.Ordinal) + .Select(static pair => new DependencySuppressionReasonSummary( + pair.Key, + pair.Value.EdgesAffected, + pair.Value.EdgesRemoved, + pair.Value.ReferencesRemoved)) + .ToList(); return new DependencySymbolFilterResult( filteredEdges, new DependencySymbolFilterSummary( @@ -1595,9 +1898,13 @@ private static DependencySymbolFilterResult ApplyDependencySymbolFilters(IReadOn SymbolsAfter: symbolsAfter, ReferencesBefore: referencesBefore, ReferencesAfter: referencesAfter, - SuppressionReasons: suppressionReasons)); + SuppressionReasons: suppressionReasonSummaries)); } + private static string? GetDependencySuppressionReason(FileDependencyEvidence evidence) + => evidence.SuppressionReason + ?? (evidence.Origin == "markdown_heading_name_match" ? "markdown_heading_name_match" : null); + private static bool KeepDependencySymbol( string symbol, QueryCommandOptions options, @@ -1674,7 +1981,7 @@ private static void AddDependencyGraphAvailabilityJsonFields(JsonObject payload, payload["note"] = "symbol_references table is missing in this index (legacy or read-only DB). Zero result is degraded, not authoritative."; } - private static void AddDependencySymbolFilterJsonFields(JsonObject payload, DependencySymbolFilterSummary symbolFilter, JsonSerializerOptions jsonOptions) + internal static void AddDependencySymbolFilterJsonFields(JsonObject payload, DependencySymbolFilterSummary symbolFilter, JsonSerializerOptions jsonOptions) { if (!symbolFilter.Applied) return; @@ -1720,9 +2027,11 @@ private static JsonArray BuildDependencyEvidenceJson(IReadOnlyList Edges, DependencySymbolFilterSummary Summary); + internal sealed record DependencySymbolFilterResult(List Edges, DependencySymbolFilterSummary Summary); - private sealed record DependencySymbolFilterSummary( + internal sealed record DependencySymbolFilterSummary( bool Applied, bool SuppressNoise, IReadOnlyList Symbols, @@ -1752,7 +2061,7 @@ private sealed record DependencySymbolFilterSummary( long ReferencesAfter, IReadOnlyList SuppressionReasons); - private sealed record DependencySuppressionReasonSummary( + internal sealed record DependencySuppressionReasonSummary( string Reason, int EdgesAffected, int EdgesRemoved, @@ -1766,12 +2075,16 @@ private static int WriteDependencyGraph( QueryCommandOptions options, SqlGraphContractSignal sqlGraphSignal, DependencySymbolFilterSummary symbolFilter, - Action? addExtraJsonFields = null) + Action? addExtraJsonFields = null, + IReadOnlyList? explicitNodes = null) { switch (format) { case OutputFormatDot: Console.WriteLine("digraph deps {"); + if (explicitNodes != null) + foreach (var node in explicitNodes) + Console.WriteLine($" \"{EscapeDot(node)}\";"); foreach (var edge in edges) Console.WriteLine($" \"{EscapeDot(edge.SourcePath)}\" -> \"{EscapeDot(edge.TargetPath)}\" [label=\"{edge.ReferenceCount}\"];"); Console.WriteLine("}"); @@ -1779,14 +2092,23 @@ private static int WriteDependencyGraph( case OutputFormatGraphMl: Console.WriteLine(""); Console.WriteLine(""); - foreach (var node in edges.SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }).Distinct(StringComparer.Ordinal)) + foreach (var node in explicitNodes + ?? edges.SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }).Distinct(StringComparer.Ordinal).ToList()) Console.WriteLine($""); foreach (var edge in edges) Console.WriteLine($"{edge.ReferenceCount}"); Console.WriteLine(""); return CommandExitCodes.Success; case OutputFormatJsonGraph: - return WriteDependencyJsonGraph(edges, jsonOptions, reader, options, sqlGraphSignal, symbolFilter, addExtraJsonFields); + return WriteDependencyJsonGraph( + edges, + jsonOptions, + reader, + options, + sqlGraphSignal, + symbolFilter, + addExtraJsonFields, + explicitNodes); default: return CommandExitCodes.Success; } @@ -1799,16 +2121,20 @@ private static int WriteDependencyJsonGraph( QueryCommandOptions options, SqlGraphContractSignal sqlGraphSignal, DependencySymbolFilterSummary symbolFilter, - Action? addExtraJsonFields = null) + Action? addExtraJsonFields = null, + IReadOnlyList? explicitNodes = null) { - var seenNodes = new HashSet(StringComparer.Ordinal); - var nodes = new List(); - foreach (var edge in edges) + var nodes = explicitNodes?.ToList() ?? []; + if (explicitNodes == null) { - if (seenNodes.Add(edge.SourcePath)) - nodes.Add(edge.SourcePath); - if (seenNodes.Add(edge.TargetPath)) - nodes.Add(edge.TargetPath); + var seenNodes = new HashSet(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (seenNodes.Add(edge.SourcePath)) + nodes.Add(edge.SourcePath); + if (seenNodes.Add(edge.TargetPath)) + nodes.Add(edge.TargetPath); + } } var payload = new JsonObject { ["count"] = edges.Count }; @@ -2052,7 +2378,7 @@ internal static bool AddBoundedWorkspaceCycleCandidates( return false; } - private static List OrderWorkspaceCycleCandidates( + internal static List OrderWorkspaceCycleCandidates( IEnumerable results, int limit) => results @@ -2066,9 +2392,9 @@ private static List OrderWorkspaceCycleCandidates( .Take(limit) .ToList(); - private static bool HasRetainedDependencyEvidence(FileDependencyResult result) + internal static bool HasRetainedDependencyEvidence(FileDependencyResult result) => result.Evidence is not { Count: > 0 } - || result.Evidence.Any(static evidence => evidence.Origin != "markdown_heading_name_match"); + || result.Evidence.Any(static evidence => GetDependencySuppressionReason(evidence) == null); internal static List BuildWorkspaceDependencyDatabaseList(QueryCommandOptions options) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index ddf86ad8e..1d433393b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -297,6 +297,7 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso { query["cycles"] = true; query["graph_budget"] = options.DependencyCycleGraphBudget; + query["all_cycle_nodes"] = options.IncludeAllDependencyCycleNodes; if (options.DependencyCycleCursor.HasValue) { query["cursor"] = FormatDependencyCycleCursor(options.DependencyCycleCursor.Value); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 28eb4e5b5..1065556fb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -24,6 +24,7 @@ public static partial class QueryCommandRunner internal const int DefaultImpactLimit = 50; internal const int DefaultDependencyCycleGraphBudget = 10_000; internal const int MaxDependencyCycleGraphBudget = 1_000_000; + internal const int DefaultDependencyCycleNodeLimit = 50; internal const int GraphLivenessLimitThreshold = 80; internal const string DependencyCycleDetectionMode = "deterministic_scc"; internal const int MaxWorkspaceDependencyDatabaseCount = 8; @@ -208,6 +209,7 @@ public sealed class QueryCommandOptions public bool MapSummaryOnly { get; init; } public bool DependencyCycles { get; init; } public int DependencyCycleGraphBudget { get; init; } = QueryCommandRunner.DefaultDependencyCycleGraphBudget; + public bool IncludeAllDependencyCycleNodes { get; init; } public bool DependencySuppressNoise { get; init; } public List DependencySymbols { get; init; } = []; public List DependencySymbolFamilies { get; init; } = []; diff --git a/src/CodeIndex/Database/DbReader.DependencyCycleCandidateSql.cs b/src/CodeIndex/Database/DbReader.DependencyCycleCandidateSql.cs index bdd25284c..766216ed9 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCycleCandidateSql.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCycleCandidateSql.cs @@ -33,12 +33,7 @@ private void AppendCandidateEdgeQuery() WITH candidate_edges AS ( SELECT src.path AS source_path, dst.path AS target_path, - MAX(CASE - WHEN src.lang = 'markdown' - AND s.kind = 'heading' - AND NOT " + _expressions.MarkdownExplicitLink + @" THEN 0 - ELSE 1 - END) AS retained_evidence + MAX(CASE WHEN " + _expressions.SuppressedEvidenceScope + @" THEN 0 ELSE 1 END) AS retained_evidence FROM symbol_references r JOIN files src ON r.file_id = src.id JOIN symbols s ON s.name = r.symbol_name diff --git a/src/CodeIndex/Database/DbReader.DependencyCycleEvidenceSql.cs b/src/CodeIndex/Database/DbReader.DependencyCycleEvidenceSql.cs index 9d7946840..3332f56f0 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCycleEvidenceSql.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCycleEvidenceSql.cs @@ -41,8 +41,16 @@ candidate_symbols AS ( THEN 'markdown_heading_name_match' ELSE 'symbol_name_match' END AS origin, + " + _expressions.ResolutionState + @" AS resolution_state, r.reference_kind AS raw_reference_kind, - CASE WHEN s.kind = 'heading' THEN 'heading' ELSE 'symbol' END AS target_kind + CASE WHEN s.kind = 'heading' THEN 'heading' ELSE 'symbol' END AS target_kind, + CASE + WHEN src.lang = 'markdown' AND s.kind = 'heading' AND NOT " + _expressions.MarkdownExplicitLink + @" + THEN 'markdown_heading_name_match' + WHEN " + _expressions.CSharpNonAuthoritativeQualifiedCall + @" + THEN 'csharp_non_authoritative_qualified_call' + ELSE NULL + END AS suppression_reason FROM candidate_edges JOIN files src ON src.path = candidate_edges.source_path JOIN symbol_references r ON r.file_id = src.id @@ -82,16 +90,20 @@ edge_evidence_rows AS ( target_path, source_lang, origin, + resolution_state, raw_reference_kind, target_kind, + suppression_reason, COUNT(DISTINCT reference_id) AS evidence_reference_count FROM candidate_symbols GROUP BY source_path, target_path, source_lang, origin, + resolution_state, raw_reference_kind, - target_kind + target_kind, + suppression_reason ),"); } @@ -103,11 +115,13 @@ ordered_edge_evidence AS ( target_path, source_lang || char(31) || origin || char(31) || + resolution_state || char(31) || raw_reference_kind || char(31) || target_kind || char(31) || + COALESCE(suppression_reason, '') || char(31) || evidence_reference_count AS evidence_item FROM edge_evidence_rows - ORDER BY source_path, target_path, source_lang, origin, raw_reference_kind, target_kind + ORDER BY source_path, target_path, source_lang, origin, resolution_state, raw_reference_kind, target_kind, suppression_reason ), edge_evidence_payloads AS ( SELECT source_path, diff --git a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs index 39b977b36..28884db3c 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs @@ -8,10 +8,13 @@ private sealed record DependencyCycleQueryPlan( private sealed record DependencyCycleQueryExpressions( string MarkdownExplicitLink, + string CSharpNonAuthoritativeQualifiedCall, + string SuppressedEvidenceScope, string NoiseEvidenceScope, string CandidateOrder, string RetainedSymbolFilter, - string ConstrainedAlias); + string ConstrainedAlias, + string ResolutionState); private DependencyCycleQueryPlan BuildDependencyCycleQueryPlan(DependencyQueryRequest request) { @@ -30,19 +33,47 @@ private DependencyCycleQueryPlan BuildDependencyCycleQueryPlan(DependencyQueryRe private DependencyCycleQueryExpressions BuildDependencyCycleQueryExpressions(DependencyQueryRequest request) { + var hasCurrentReferenceIdentityContract = HasCurrentReferenceIdentityContractForRead(); var markdownExplicitLink = _referenceColumns.Contains("target_qualifier") ? "(src.lang = 'markdown' AND r.reference_kind = 'reference' AND r.target_qualifier IS NOT NULL AND dst.path = markdown_resolve_path(src.path, r.target_qualifier))" : "0 = 1"; + var resolvedCSharpNonTarget = hasCurrentReferenceIdentityContract + && _referenceColumns.Contains("target_symbol_id") + ? "(r.resolution_state = 'resolved' AND r.target_symbol_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM symbols confirmed_target WHERE confirmed_target.id = r.target_symbol_id AND confirmed_target.file_id = dst.id))" + : "0 = 1"; + var resolvedGroupCSharpNonCandidate = hasCurrentReferenceIdentityContract + ? "(r.resolution_state = 'resolved_group' AND NOT EXISTS (SELECT 1 FROM symbol_reference_candidates confirmed_candidate JOIN symbols confirmed_target ON confirmed_target.id = confirmed_candidate.symbol_id WHERE confirmed_candidate.reference_id = r.id AND confirmed_target.file_id = dst.id))" + : "0 = 1"; + var csharpNonAuthoritativeQualifiedCall = hasCurrentReferenceIdentityContract + && _referenceColumns.Contains("target_qualifier") + && _referenceColumns.Contains("resolution_state") + && _referenceColumns.Contains("target_symbol_id") + ? "(src.lang = 'csharp' AND r.reference_kind = 'call' AND r.target_qualifier IS NOT NULL AND (COALESCE(r.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group') OR " + + resolvedCSharpNonTarget + + " OR " + + resolvedGroupCSharpNonCandidate + + "))" + : "0 = 1"; + var suppressedEvidenceScope = "((src.lang = 'markdown' AND s.kind = 'heading' AND NOT " + + markdownExplicitLink + + ") OR " + + csharpNonAuthoritativeQualifiedCall + + ")"; return new DependencyCycleQueryExpressions( markdownExplicitLink, - "(" + markdownExplicitLink + " OR (src.lang = 'markdown' AND s.kind = 'heading'))", + csharpNonAuthoritativeQualifiedCall, + suppressedEvidenceScope, + "(" + markdownExplicitLink + " OR (src.lang = 'markdown' AND s.kind = 'heading') OR " + csharpNonAuthoritativeQualifiedCall + ")", request.SuppressDependencyNoise ? "retained_evidence DESC, source_path, target_path" : "source_path, target_path", request.SuppressDependencyNoise - ? " WHERE origin <> 'markdown_heading_name_match'" + ? " WHERE suppression_reason IS NULL" : string.Empty, - request.Reverse ? "dst" : "src"); + request.Reverse ? "dst" : "src", + hasCurrentReferenceIdentityContract && _referenceColumns.Contains("resolution_state") + ? "COALESCE(r.resolution_state, 'unavailable')" + : "'unavailable'"); } private static void AppendDependencyCycleTerminalParameters( diff --git a/src/CodeIndex/Database/DbReader.DependencyProjection.cs b/src/CodeIndex/Database/DbReader.DependencyProjection.cs index cb733d9b2..20437b21c 100644 --- a/src/CodeIndex/Database/DbReader.DependencyProjection.cs +++ b/src/CodeIndex/Database/DbReader.DependencyProjection.cs @@ -28,16 +28,20 @@ internal static List ParseDependencyEvidence(string payl foreach (var item in payload.Split('\u001e', StringSplitOptions.RemoveEmptyEntries)) { var fields = item.Split('\u001f'); - if (fields.Length != 5 || !int.TryParse(fields[4], out var referenceCount)) + var currentPayload = fields.Length == 7; + var referenceCountField = currentPayload ? 6 : 4; + if (fields.Length is not (5 or 7) || !int.TryParse(fields[referenceCountField], out var referenceCount)) continue; evidence.Add(new FileDependencyEvidence { SourceLanguage = fields[0], Origin = fields[1], - ReferenceKind = fields[2], - TargetKind = fields[3], + ResolutionState = currentPayload ? fields[2] : "unavailable", + ReferenceKind = fields[currentPayload ? 3 : 2], + TargetKind = fields[currentPayload ? 4 : 3], ReferenceCount = referenceCount, + SuppressionReason = currentPayload && fields[5].Length > 0 ? fields[5] : null, }); } diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index e596989f2..131d64ee5 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -28,7 +28,7 @@ public partial class McpServer "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format", "fields" }, "outline" => new HashSet(StringComparer.Ordinal) { "path", "fields", "sort", "limit", "cursor", "maxBytes" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, - "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "graphBudget", "cursor", "excludePaths", "excludeTests", "includeGenerated", "project", "solution" }, + "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "graphBudget", "cursor", "suppressNoise", "summaryOnly", "includeAllCycleNodes", "excludePaths", "excludeTests", "includeGenerated", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "selector", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "includeMemberReads", "countOnly", "project", "solution" }, "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "language", "extension", "alias", "limit", "cursor", "maxBytes" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "cursor", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index fccb28b00..7510962fb 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -607,6 +607,9 @@ private static JsonObject[] CreateWorkspaceInspectionTools() ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "edgelist", "json-graph" }, ["description"] = "Structured response format. `edgelist` preserves the existing edges array; `json-graph` returns nodes and edges.", ["default"] = "edgelist" }, ["cycles"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return stable ranked strongly connected components instead of ordinary edge rows. `limit` paginates the completed analysis; inspect `analysis_complete` and continue with opaque `next_cursor` values. / 通常の edge 行ではなく安定順位付きの強連結成分を返す。`limit` は完了した解析結果をページ分割する。`analysis_complete` を確認し、不透明な `next_cursor` で続きを取得する。", ["default"] = false }, ["graphBudget"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["maximum"] = QueryCommandRunner.MaxDependencyCycleGraphBudget, ["description"] = "Maximum dependency edges analyzed for `cycles`, independent of the display `limit`. / 表示用 `limit` と独立した、`cycles` 解析対象の依存 edge 上限。", ["default"] = QueryCommandRunner.DefaultDependencyCycleGraphBudget }, + ["suppressNoise"] = new JsonObject { ["type"] = "boolean", ["description"] = "For cycle analysis, suppress documented non-authoritative Markdown and qualified C# evidence while reporting removed counts and reasons. / cycle 解析で、文書化された非 authoritative な Markdown および修飾付き C# evidence を抑制し、除外件数と理由を報告する。", ["default"] = false }, + ["summaryOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return bounded SCC metrics and node samples without the regular cycles collection; incompatible with format=json-graph. / 通常の cycles collection を省略し、上限付き SCC metrics と node sample を返す。format=json-graph とは併用不可。", ["default"] = false }, + ["includeAllCycleNodes"] = new JsonObject { ["type"] = "boolean", ["description"] = $"Expand every path in returned SCCs instead of the default {QueryCommandRunner.DefaultDependencyCycleNodeLimit}-node sample. / 既定の {QueryCommandRunner.DefaultDependencyCycleNodeLimit} node sample ではなく、返却 SCC の全 path を展開する。", ["default"] = false }, ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = 256, ["description"] = "Opaque dependency-cycle `next_cursor`; reuse the same filters and graphBudget. / 同じ filter と graphBudget で再利用する不透明な dependency-cycle `next_cursor`。" } } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs index 7cca0709f..cb3901210 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs @@ -273,7 +273,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "lspCompatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or - "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" or + "optimize" or "reverse" or "cycles" or "suppressNoise" or "summaryOnly" or "includeAllCycleNodes" or "config" or "logPath" or "updateCheck" or "rawKinds" or "includeQualifiedCommonCalls" or "includeMemberReads" or "orderBySize" or "rawBytes" or "byBucket" or "memoryTrace" or "watch" or "estimateOnly" or "listRecipes" => "boolean", "project" or "capability" or "scopes" or "fields" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs index ae5975966..3aa4b2f62 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs @@ -34,12 +34,19 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; var reverse = args?["reverse"]?.GetValue() ?? false; var cyclesOnly = args?["cycles"]?.GetValue() ?? false; + var suppressNoise = args?["suppressNoise"]?.GetValue() ?? false; + var summaryOnly = args?["summaryOnly"]?.GetValue() ?? false; + var includeAllCycleNodes = args?["includeAllCycleNodes"]?.GetValue() ?? false; var format = args?["format"]?.GetValue()?.ToLowerInvariant() ?? "edgelist"; var cursorValue = args?["cursor"]?.GetValue(); if (requestedGraphBudget.HasValue && !cyclesOnly) return CreateToolErrorResponse(id, "'graphBudget' requires 'cycles=true'."); if (cursorValue != null && !cyclesOnly) return CreateToolErrorResponse(id, "'cursor' requires 'cycles=true'."); + if ((suppressNoise || summaryOnly || includeAllCycleNodes) && !cyclesOnly) + return CreateToolErrorResponse(id, "'suppressNoise', 'summaryOnly', and 'includeAllCycleNodes' require 'cycles=true'."); + if (summaryOnly && format == "json-graph") + return CreateToolErrorResponse(id, "'summaryOnly' is not supported with format='json-graph' because summary mode does not emit graph-shaped nodes or edges."); if (cursorValue != null && !QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out _)) return CreateToolErrorResponse(id, "'cursor' must be an opaque dependency-cycle next_cursor returned by deps."); @@ -51,6 +58,8 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) ExcludeTests = excludeTests, IncludeGenerated = includeGenerated, DependencyCycleGraphBudget = graphBudget, + DependencySuppressNoise = suppressNoise, + IncludeAllDependencyCycleNodes = includeAllCycleNodes, }; var cursorBaseFingerprint = QueryCommandRunner.BuildDependencyCycleCursorFingerprint(cursorOptions, reverse); var cursor = cursorValue == null @@ -72,9 +81,20 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) excludePaths, excludeTests, reverse, - reader.Cancellation) + cancellationToken: reader.Cancellation, + suppressDependencyNoise: suppressNoise) : reader.GetFileDependencies(limit, lang, pathPatterns, excludePaths, excludeTests, reverse); - var cycleCandidates = cyclesOnly ? results.Take(graphBudget).ToList() : results; + if (cyclesOnly && suppressNoise) + cycleCandidateRowCount = results.Count(QueryCommandRunner.HasRetainedDependencyEvidence); + var rawCycleCandidates = cyclesOnly + ? suppressNoise + ? QueryCommandRunner.OrderWorkspaceCycleCandidates(results, graphBudget) + : results.Take(graphBudget).ToList() + : results; + var cycleFilter = cyclesOnly + ? QueryCommandRunner.ApplyDependencySymbolFilters(rawCycleCandidates, cursorOptions) + : null; + var cycleCandidates = cycleFilter?.Edges ?? rawCycleCandidates; var cursorFingerprint = QueryCommandRunner.BuildDependencyCycleGraphFingerprint( cursorBaseFingerprint, cycleCandidates, @@ -110,14 +130,37 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) sqlGraphSignalPaths, lang); var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; - if (cyclesOnly) - payload["cycles"] = QueryCommandRunner.BuildDependencyCyclesJson(cycleAnalysis!.Components, cycleAnalysis.PageOffset); + if (cyclesOnly && format == "json-graph") + { + var graphProjection = QueryCommandRunner.BuildDependencyCycleGraphProjection( + cycleAnalysis!.Edges, + cycleAnalysis.Components, + includeAllCycleNodes); + payload["graph"] = BuildJsonGraphPayload(graphProjection.Edges, graphProjection.Nodes); + } + else if (cyclesOnly) + { + payload[summaryOnly ? "cycle_summaries" : "cycles"] = QueryCommandRunner.BuildDependencyCyclesJson( + cycleAnalysis!.Components, + cycleAnalysis.PageOffset, + includeAllCycleNodes, + mcpArguments: true); + } else if (format == "json-graph") payload["graph"] = BuildJsonGraphPayload(outputEdges); else payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, _jsonOptions); if (cyclesOnly) - QueryCommandRunner.AddDependencyCycleAnalysisJsonFields(payload, cycleAnalysis!, mcpArguments: true); + { + QueryCommandRunner.AddDependencyCycleAnalysisJsonFields( + payload, + cycleAnalysis!, + mcpArguments: true, + includeAllNodes: includeAllCycleNodes); + QueryCommandRunner.AddDependencySymbolFilterJsonFields(payload, cycleFilter!.Summary, _jsonOptions); + if (summaryOnly) + payload["summary_only"] = true; + } payload["format"] = format; payload["includeGenerated"] = includeGenerated; payload["generated_code_filter_supported"] = true; @@ -140,11 +183,18 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) }); } - private static JsonObject BuildJsonGraphPayload(IReadOnlyList edges) + private static JsonObject BuildJsonGraphPayload( + IReadOnlyList edges, + IReadOnlyList? projectedNodes = null) { var nodes = new JsonArray(); var seenNodes = new HashSet(StringComparer.Ordinal); var graphEdges = new JsonArray(); + foreach (var node in projectedNodes ?? []) + { + if (seenNodes.Add(node)) + nodes.Add(new JsonObject { ["id"] = node }); + } foreach (var edge in edges) { if (seenNodes.Add(edge.SourcePath)) diff --git a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs index bfadc841e..501bf1d69 100644 --- a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs +++ b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Database; namespace CodeIndex.Mcp; @@ -69,6 +70,8 @@ public static JsonObject Create(string toolName) { ["row"] = RowSchema(), ["rows"] = ArraySchema(Reference("row")), + ["dependency_cycle"] = DependencyCycleSchema(), + ["dependency_cycles"] = ArraySchema(Reference("dependency_cycle")), ["warning"] = new JsonObject { ["oneOf"] = new JsonArray @@ -177,6 +180,7 @@ private static JsonArray RequiredToolProperties(string toolName) RequiredSchema("edges"), RequiredSchema("graph"), RequiredSchema("cycles"), + RequiredSchema("cycle_summaries"), }, _ => null, }; @@ -395,15 +399,21 @@ private static JsonObject DependencyProperties() => new() { ["edges"] = Reference("rows"), - ["cycles"] = Reference("rows"), + ["cycles"] = Reference("dependency_cycles"), + ["cycle_summaries"] = Reference("dependency_cycles"), + ["largest_component"] = Nullable(Reference("dependency_cycle")), ["graph"] = new JsonObject { ["type"] = "object", ["required"] = StringArray("nodes", "edges"), ["properties"] = new JsonObject { - ["nodes"] = Reference("rows"), - ["edges"] = Reference("rows"), + ["nodes"] = ArraySchema( + Reference("row"), + QueryCommandRunner.MaxDependencyCycleGraphBudget), + ["edges"] = ArraySchema( + Reference("row"), + QueryCommandRunner.MaxDependencyCycleGraphBudget), }, ["maxProperties"] = MaxSchemaObjectProperties, ["propertyNames"] = StringSchema(), @@ -412,6 +422,21 @@ private static JsonObject DependencyProperties() ["format"] = StringSchema(), }; + private static JsonObject DependencyCycleSchema() + => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["nodes"] = ArraySchema( + StringSchema(), + QueryCommandRunner.MaxDependencyCycleGraphBudget), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + private static JsonObject LanguagesProperties() => new() { @@ -635,11 +660,14 @@ private static JsonObject NullSchema() => new() { ["type"] = "null" }; private static JsonObject ArraySchema(JsonObject itemSchema) + => ArraySchema(itemSchema, MaxSchemaArrayItems); + + private static JsonObject ArraySchema(JsonObject itemSchema, int maxItems) => new() { ["type"] = "array", ["items"] = itemSchema, - ["maxItems"] = MaxSchemaArrayItems, + ["maxItems"] = maxItems, }; private static JsonObject Nullable(JsonObject schema) diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 59cd5bfa5..03aebd7df 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2962,7 +2962,10 @@ public class FileDependencyEvidence { public string SourceLanguage { get; set; } = string.Empty; public string Origin { get; set; } = string.Empty; + public string ResolutionState { get; set; } = string.Empty; public string ReferenceKind { get; set; } = string.Empty; public string TargetKind { get; set; } = string.Empty; public int ReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SuppressionReason { get; set; } } diff --git a/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs b/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs index ac6700e75..54e2cc764 100644 --- a/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs +++ b/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs @@ -248,6 +248,35 @@ public void RunExcerpt_JsonOutput_MatchesGolden() } } + [Fact] + public void RunDepsCyclesSummary_JsonOutput_MatchesGolden_Issue5197() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_snapshot_deps_cycles_5197"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + InsertDependencyCycleSnapshotFile(dbPath, "src/CycleA.cs", "CycleA", "CycleB"); + InsertDependencyCycleSnapshotFile(dbPath, "src/CycleB.cs", "CycleB", "CycleC"); + InsertDependencyCycleSnapshotFile(dbPath, "src/CycleC.cs", "CycleC", "CycleA"); + MarkGraphAndFoldReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--summary-only", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + JsonOutputSnapshotHelper.AssertMatches( + "deps-cycles-summary.json", + stdout, + BuildPathReplacements(projectRoot)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSuggestionsCompact_JsonOutput_MatchesGolden_Issue5061() { @@ -304,6 +333,47 @@ private static void MarkGraphAndFoldReady(string dbPath) writer.MarkIssuesReady(); } + private static void InsertDependencyCycleSnapshotFile( + string dbPath, + string path, + string symbolName, + string referenceName) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = 1, + Lines = 1, + Modified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Checksum = $"snapshot-{symbolName}", + }); + writer.InsertSymbols([ + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = symbolName, + Line = 1, + StartLine = 1, + EndLine = 1, + }, + ]); + writer.InsertReferences([ + new ReferenceRecord + { + FileId = fileId, + SymbolName = referenceName, + ReferenceKind = "type_reference", + Line = 1, + Column = 1, + Context = referenceName, + }, + ]); + } + private static IReadOnlyList<(string Original, string Placeholder)> BuildPathReplacements(string projectRoot) { var canonical = Path.GetFullPath(projectRoot); diff --git a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs new file mode 100644 index 000000000..58773c99e --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -0,0 +1,181 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Mcp; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [Fact] + public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_Issue5197() + { + const int nodeCount = QueryCommandRunner.DefaultDependencyCycleNodeLimit + 5; + var writer = new DbWriter(_db.Connection); + var fileIds = Enumerable.Range(0, nodeCount) + .Select(index => InsertDependencyFile(writer, $"src/LargeCycle{index:D2}.cs")) + .ToArray(); + + for (var index = 0; index < nodeCount; index++) + { + InsertDependencySymbols(writer, fileIds[index], [$"LargeCycle{index:D2}"]); + InsertDependencyReferences(writer, fileIds[index], [$"LargeCycle{(index + 1) % nodeCount:D2}"]); + } + + var summaryRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"summaryOnly":true,"limit":1,"lang":"csharp"}}}""")!; + var summaryResponse = _server.HandleMessage(summaryRequest)!; + var summary = summaryResponse["result"]!["structuredContent"]!; + var cycleSummary = Assert.Single(summary["cycle_summaries"]!.AsArray())!; + var nextStepFlags = summary["next_step_flags"]!.AsArray() + .Select(flag => flag!.GetValue()) + .ToArray(); + + Assert.Null(summary["cycles"]); + Assert.Equal(nodeCount, cycleSummary["node_count"]!.GetValue()); + Assert.Equal(QueryCommandRunner.DefaultDependencyCycleNodeLimit, cycleSummary["nodes_returned"]!.GetValue()); + Assert.Equal(5, cycleSummary["nodes_omitted_count"]!.GetValue()); + Assert.True(summary["analysis_complete"]!.GetValue()); + Assert.True(summary["display_truncated"]!.GetValue()); + Assert.Contains("includeAllCycleNodes=true", nextStepFlags); + + var toolsListResponse = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"format":"full","names":"deps"}}""")!)!; + var depsSchema = Assert.Single(toolsListResponse["result"]!["tools"]!.AsArray())!["outputSchema"]!.AsObject(); + Assert.True(MatchesSchema(summary, depsSchema, depsSchema), summary.ToJsonString()); + + var jsonGraphRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"format":"json-graph","limit":1,"lang":"csharp"}}}""")!; + var jsonGraphResponse = _server.HandleMessage(jsonGraphRequest)!; + var jsonGraph = jsonGraphResponse["result"]!["structuredContent"]!; + var graph = jsonGraph["graph"]!; + var graphNodes = graph["nodes"]!.AsArray(); + var graphNodeIds = graphNodes + .Select(node => node!["id"]!.GetValue()) + .ToHashSet(StringComparer.Ordinal); + + Assert.Null(jsonGraph["cycles"]); + Assert.Equal(QueryCommandRunner.DefaultDependencyCycleNodeLimit, graphNodes.Count); + Assert.All(graph["edges"]!.AsArray(), edge => + { + Assert.Contains(edge!["source"]!.GetValue(), graphNodeIds); + Assert.Contains(edge["target"]!.GetValue(), graphNodeIds); + }); + Assert.Equal(nodeCount, jsonGraph["returned_node_count"]!.GetValue()); + Assert.Equal(QueryCommandRunner.DefaultDependencyCycleNodeLimit, jsonGraph["returned_nodes_materialized"]!.GetValue()); + Assert.Equal(5, jsonGraph["returned_nodes_omitted_count"]!.GetValue()); + Assert.True(MatchesSchema(jsonGraph, depsSchema, depsSchema), jsonGraph.ToJsonString()); + + var expandedRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"includeAllCycleNodes":true,"limit":1,"lang":"csharp"}}}""")!; + var expandedResponse = _server.HandleMessage(expandedRequest)!; + var expanded = expandedResponse["result"]!["structuredContent"]!; + var expandedCycle = Assert.Single(expanded["cycles"]!.AsArray())!; + + Assert.Equal(nodeCount, expandedCycle["node_count"]!.GetValue()); + Assert.Equal(nodeCount, expandedCycle["nodes_returned"]!.GetValue()); + Assert.False(expanded["display_truncated"]!.GetValue()); + Assert.True(MatchesSchema(expanded, depsSchema, depsSchema), expanded.ToJsonString()); + + var schemaBoundaryPayload = expanded.DeepClone(); + var schemaBoundaryNodes = new JsonArray(Enumerable + .Range(0, McpServer.MaxMcpPaginationOffset + 1) + .Select(index => (JsonNode?)JsonValue.Create($"src/SchemaBoundary{index:D5}.cs")) + .ToArray()); + schemaBoundaryPayload["cycles"]![0]!["nodes"] = schemaBoundaryNodes; + schemaBoundaryPayload["largest_component"]!["nodes"] = schemaBoundaryNodes.DeepClone(); + Assert.Equal( + QueryCommandRunner.MaxDependencyCycleGraphBudget, + depsSchema["$defs"]!["dependency_cycle"]!["properties"]!["nodes"]!["maxItems"]!.GetValue()); + Assert.Equal( + QueryCommandRunner.MaxDependencyCycleGraphBudget, + depsSchema["$defs"]!["tool_result"]!["properties"]!["graph"]!["properties"]!["nodes"]!["maxItems"]!.GetValue()); + Assert.Equal( + QueryCommandRunner.MaxDependencyCycleGraphBudget, + depsSchema["$defs"]!["tool_result"]!["properties"]!["graph"]!["properties"]!["edges"]!["maxItems"]!.GetValue()); + Assert.True( + MatchesSchema(schemaBoundaryPayload, depsSchema, depsSchema), + schemaBoundaryPayload.ToJsonString()); + } + + [Fact] + public void ToolsCall_DepsRejectsSummaryOnlyJsonGraphCombination_Issue5197() + { + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"summaryOnly":true,"format":"json-graph"}}}""")!; + var response = _server.HandleMessage(request)!; + var result = response["result"]!; + + Assert.True(result["isError"]!.GetValue()); + Assert.Contains( + "'summaryOnly' is not supported with format='json-graph'", + result["content"]![0]!["text"]!.GetValue(), + StringComparison.Ordinal); + } + + [Fact] + public void ToolsCall_DepsNoiseSuppressionPreservesResolvedCSharpCalls_Issue5197() + { + var writer = new DbWriter(_db.Connection); + var fallbackAId = InsertDependencyFile(writer, "src/McpFallbackA.cs"); + var fallbackBId = InsertDependencyFile(writer, "src/McpFallbackB.cs"); + var resolvedAId = InsertDependencyFile(writer, "src/McpResolvedA.cs"); + var resolvedBId = InsertDependencyFile(writer, "src/McpResolvedB.cs"); + var resolvedBDecoyId = InsertDependencyFile(writer, "src/McpResolvedBDecoy.cs"); + InsertDependencySymbols(writer, fallbackAId, ["McpFallbackA"]); + InsertDependencyReferences(writer, fallbackAId, ["McpFallbackB"]); + InsertDependencySymbols(writer, fallbackBId, ["McpFallbackB"]); + InsertDependencyReferences(writer, fallbackBId, ["McpFallbackA"]); + InsertDependencySymbols(writer, resolvedAId, ["McpResolvedA"]); + InsertDependencyReferences(writer, resolvedAId, ["McpResolvedB"]); + InsertDependencySymbols(writer, resolvedBId, ["McpResolvedB", "McpResolvedB"]); + InsertDependencyReferences(writer, resolvedBId, ["McpResolvedA"]); + InsertDependencySymbols(writer, resolvedBDecoyId, ["McpResolvedB"]); + InsertDependencyReferences(writer, resolvedBDecoyId, ["McpResolvedA"]); + SetMcpCycleReferenceResolution("src/McpFallbackA.cs", "src/McpFallbackB.cs", "unresolved"); + SetMcpCycleReferenceResolution("src/McpFallbackB.cs", "src/McpFallbackA.cs", "resolved"); + SetMcpCycleReferenceResolution("src/McpResolvedA.cs", "src/McpResolvedB.cs", "resolved"); + SetMcpCycleReferenceResolution("src/McpResolvedB.cs", "src/McpResolvedA.cs", "resolved"); + SetMcpCycleReferenceResolution("src/McpResolvedBDecoy.cs", "src/McpResolvedA.cs", "resolved"); + + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"suppressNoise":true,"limit":10,"lang":"csharp"}}}""")!; + var response = _server.HandleMessage(request)!; + var payload = response["result"]!["structuredContent"]!; + var cycle = Assert.Single(payload["cycles"]!.AsArray())!; + var nodes = cycle["nodes"]!.AsArray().Select(node => node!.GetValue()).ToArray(); + var reason = Assert.Single(payload["symbol_filter"]!["suppression_reasons"]!.AsArray())!; + + Assert.Equal(["src/McpResolvedA.cs", "src/McpResolvedB.cs"], nodes); + Assert.Equal("csharp_non_authoritative_qualified_call", reason["reason"]!.GetValue()); + Assert.Equal(2, reason["references_removed"]!.GetValue()); + Assert.True(cycle["retained_evidence"]!["classification_complete"]!.GetValue()); + Assert.True(payload["analysis_complete"]!.GetValue()); + } + + private void SetMcpCycleReferenceResolution(string sourcePath, string targetPath, string resolutionState) + { + using var command = _db.Connection.CreateCommand(); + command.CommandText = """ + UPDATE symbol_references + SET reference_kind = 'call', + target_qualifier = 'Receiver', + resolution_state = $resolutionState, + resolution_candidate_count = 1, + target_symbol_id = CASE WHEN $resolutionState = 'resolved' THEN ( + SELECT s.id + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.path = $targetPath + AND s.name = symbol_references.symbol_name + ORDER BY s.id + LIMIT 1 + ) ELSE NULL END + WHERE file_id = (SELECT id FROM files WHERE path = $sourcePath) + """; + command.Parameters.AddWithValue("$sourcePath", sourcePath); + command.Parameters.AddWithValue("$targetPath", targetPath); + command.Parameters.AddWithValue("$resolutionState", resolutionState); + Assert.Equal(1, command.ExecuteNonQuery()); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs new file mode 100644 index 000000000..795dc0fba --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -0,0 +1,492 @@ +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_large_cycle_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + const int nodeCount = QueryCommandRunner.DefaultDependencyCycleNodeLimit + 5; + for (var i = 0; i < nodeCount; i++) + { + InsertFileWithSymbolsAndReferences( + dbPath, + $"src/Node{i:D2}.cs", + [$"Node{i:D2}"], + [$"Node{(i + 1) % nodeCount:D2}"]); + } + MarkDependencyGraphReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--summary-only", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var summary = Assert.Single(json.GetProperty("cycle_summaries").EnumerateArray()); + var largest = json.GetProperty("largest_component"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.False(json.TryGetProperty("cycles", out _)); + Assert.True(json.GetProperty("analysis_complete").GetBoolean()); + Assert.True(json.GetProperty("display_truncated").GetBoolean()); + Assert.Equal("component_node_limit", json.GetProperty("display_truncation_reason").GetString()); + Assert.Equal("bounded_sample", json.GetProperty("node_materialization_mode").GetString()); + Assert.Equal(nodeCount, summary.GetProperty("node_count").GetInt32()); + Assert.Equal(nodeCount, summary.GetProperty("internal_edge_count").GetInt32()); + Assert.Equal(nodeCount, summary.GetProperty("reference_count").GetInt64()); + Assert.Equal(QueryCommandRunner.DefaultDependencyCycleNodeLimit, summary.GetProperty("nodes_returned").GetInt32()); + Assert.Equal(5, summary.GetProperty("nodes_omitted_count").GetInt32()); + Assert.Equal(nodeCount, largest.GetProperty("node_count").GetInt32()); + Assert.Equal("file", json.GetProperty("cycle_grouping_mode").GetString()); + Assert.False(json.GetProperty("cycle_grouping_applied").GetBoolean()); + Assert.Contains( + "--all-cycle-nodes", + json.GetProperty("next_step_flags").EnumerateArray().Select(static value => value.GetString())); + + var (expandedExitCode, expandedStdout, expandedStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--all-cycle-nodes", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + + using var expandedDocument = ParseJsonOutput(expandedStdout); + var expandedJson = expandedDocument.RootElement; + var expandedCycle = Assert.Single(expandedJson.GetProperty("cycles").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, expandedExitCode); + Assert.Equal(string.Empty, expandedStderr); + Assert.False(expandedJson.GetProperty("display_truncated").GetBoolean()); + Assert.True(expandedJson.GetProperty("analysis_complete").GetBoolean()); + Assert.Equal("complete", expandedJson.GetProperty("node_materialization_mode").GetString()); + Assert.Equal(nodeCount, expandedCycle.GetProperty("nodes_returned").GetInt32()); + Assert.False(expandedCycle.GetProperty("nodes_truncated").GetBoolean()); + + var (humanExitCode, humanStdout, humanStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Contains("(1 dependency cycles)", humanStderr); + Assert.Contains("5 nodes omitted; rerun with --all-cycle-nodes", humanStdout); + Assert.DoesNotContain("src/Node54.cs", humanStdout); + + var (expandedHumanExitCode, expandedHumanStdout, expandedHumanStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--all-cycle-nodes", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, expandedHumanExitCode); + Assert.Contains("(1 dependency cycles)", expandedHumanStderr); + Assert.Contains("src/Node54.cs", expandedHumanStdout); + Assert.DoesNotContain("nodes omitted", expandedHumanStdout); + + var (jsonGraphExitCode, jsonGraphStdout, jsonGraphStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--format", "json-graph", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + using var jsonGraphDocument = ParseJsonOutput(jsonGraphStdout); + var jsonGraph = jsonGraphDocument.RootElement; + var graphNodes = jsonGraph.GetProperty("nodes").EnumerateArray().ToArray(); + var graphNodeIds = graphNodes + .Select(static node => node.GetProperty("id").GetString()) + .ToHashSet(StringComparer.Ordinal); + Assert.Equal(CommandExitCodes.Success, jsonGraphExitCode); + Assert.Equal(string.Empty, jsonGraphStderr); + Assert.Equal(QueryCommandRunner.DefaultDependencyCycleNodeLimit, graphNodes.Length); + Assert.DoesNotContain("src/Node54.cs", graphNodeIds); + Assert.All(jsonGraph.GetProperty("edges").EnumerateArray(), edge => + { + Assert.Contains(edge.GetProperty("source").GetString(), graphNodeIds); + Assert.Contains(edge.GetProperty("target").GetString(), graphNodeIds); + }); + Assert.True(jsonGraph.GetProperty("display_truncated").GetBoolean()); + Assert.Equal(nodeCount, jsonGraph.GetProperty("returned_node_count").GetInt32()); + Assert.Equal( + QueryCommandRunner.DefaultDependencyCycleNodeLimit, + jsonGraph.GetProperty("returned_nodes_materialized").GetInt32()); + Assert.Equal(5, jsonGraph.GetProperty("returned_nodes_omitted_count").GetInt32()); + + var (expandedGraphExitCode, expandedGraphStdout, expandedGraphStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--format", "json-graph", "--all-cycle-nodes", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + using var expandedGraphDocument = ParseJsonOutput(expandedGraphStdout); + Assert.Equal(CommandExitCodes.Success, expandedGraphExitCode); + Assert.Equal(string.Empty, expandedGraphStderr); + Assert.Equal(nodeCount, expandedGraphDocument.RootElement.GetProperty("nodes").GetArrayLength()); + Assert.Equal(nodeCount, expandedGraphDocument.RootElement.GetProperty("edges").GetArrayLength()); + Assert.False(expandedGraphDocument.RootElement.GetProperty("display_truncated").GetBoolean()); + + foreach (var graphFormat in new[] { "dot", "graphml" }) + { + var (graphExitCode, graphStdout, graphStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--format", graphFormat, "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, graphExitCode); + Assert.Contains("src/Node49.cs", graphStdout); + Assert.DoesNotContain("src/Node54.cs", graphStdout); + Assert.Contains("limited to 50 nodes per component", graphStderr); + Assert.Contains("5 nodes are omitted across 1 returned component", graphStderr); + Assert.Contains("--all-cycle-nodes", graphStderr); + } + + var (budgetExitCode, _, budgetStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--all-cycle-nodes", "--graph-budget", "10", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, budgetExitCode); + Assert.Contains("graph edge budget reached", budgetStderr); + Assert.DoesNotContain("--all-cycle-nodes", budgetStderr); + } + + [Fact] + public void RunDeps_CycleSummaryIncludesEveryAdvertisedEvidenceDimension_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_cycle_evidence_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertFileWithSymbolsAndReferences(dbPath, "src/EvidenceA.cs", ["EvidenceA"], ["EvidenceB"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/EvidenceB.cs", ["EvidenceB"], ["EvidenceA"]); + SetCycleReferenceResolution(dbPath, "src/EvidenceA.cs", "src/EvidenceB.cs", "unresolved"); + SetCycleReferenceResolution(dbPath, "src/EvidenceB.cs", "src/EvidenceA.cs", "resolved"); + MarkDependencyGraphReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--summary-only", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var evidence = Assert.Single(document.RootElement + .GetProperty("cycle_summaries") + .EnumerateArray()) + .GetProperty("retained_evidence"); + var origin = Assert.Single(evidence.GetProperty("by_origin").EnumerateArray()); + var targetKind = Assert.Single(evidence.GetProperty("by_target_kind").EnumerateArray()); + var suppressionReasons = evidence.GetProperty("by_suppression_reason") + .EnumerateArray() + .ToDictionary( + static item => item.GetProperty("suppression_reason").GetString()!, + static item => item.GetProperty("reference_count").GetInt64(), + StringComparer.Ordinal); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("symbol_name_match", origin.GetProperty("origin").GetString()); + Assert.Equal(2, origin.GetProperty("reference_count").GetInt64()); + Assert.Equal("symbol", targetKind.GetProperty("target_kind").GetString()); + Assert.Equal(2, targetKind.GetProperty("reference_count").GetInt64()); + Assert.Equal(1, suppressionReasons["csharp_non_authoritative_qualified_call"]); + Assert.Equal(1, suppressionReasons["unavailable"]); + } + + [Fact] + public void RunDeps_JsonGraphCursorPageReportsReturnedNodeOmissionSeparatelyFromLargestComponent_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_graph_page_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertDependencyCycle(dbPath, "Largest", QueryCommandRunner.DefaultDependencyCycleNodeLimit + 5); + InsertDependencyCycle(dbPath, "Paged", QueryCommandRunner.DefaultDependencyCycleNodeLimit + 2); + MarkDependencyGraphReady(dbPath); + + var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--format", "json-graph", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + using var firstDocument = ParseJsonOutput(firstStdout); + var cursor = firstDocument.RootElement.GetProperty("next_cursor").GetString(); + + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + Assert.False(string.IsNullOrEmpty(cursor)); + + var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--cycles", "--format", "json-graph", "--limit", "1", "--cursor", cursor!, "--lang", "csharp"], + _jsonOptions)); + using var secondDocument = ParseJsonOutput(secondStdout); + var secondPage = secondDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, secondExitCode); + Assert.Equal(string.Empty, secondStderr); + Assert.Equal( + QueryCommandRunner.DefaultDependencyCycleNodeLimit + 2, + secondPage.GetProperty("returned_node_count").GetInt32()); + Assert.Equal( + QueryCommandRunner.DefaultDependencyCycleNodeLimit, + secondPage.GetProperty("returned_nodes_materialized").GetInt32()); + Assert.Equal(2, secondPage.GetProperty("returned_nodes_omitted_count").GetInt32()); + Assert.Equal( + QueryCommandRunner.DefaultDependencyCycleNodeLimit, + secondPage.GetProperty("nodes").GetArrayLength()); + Assert.Equal( + 5, + secondPage.GetProperty("largest_component").GetProperty("nodes_omitted_count").GetInt32()); + } + + [Fact] + public void RunDeps_CycleCursorRejectsEvidenceOnlyGraphChanges_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_cycle_evidence_cursor_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertDependencyCycle(dbPath, "First", 2); + InsertDependencyCycle(dbPath, "Second", 2); + SetCycleReferenceResolution(dbPath, "src/First00.cs", "src/First01.cs", "resolved"); + MarkDependencyGraphReady(dbPath); + + var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + using var firstDocument = ParseJsonOutput(firstStdout); + var cursor = firstDocument.RootElement.GetProperty("next_cursor").GetString(); + + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + Assert.False(string.IsNullOrEmpty(cursor)); + + SetCycleReferenceResolution(dbPath, "src/First00.cs", "src/First01.cs", "ambiguous"); + + var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--limit", "1", "--cursor", cursor!, "--lang", "csharp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, secondExitCode); + Assert.Equal(string.Empty, secondStdout); + Assert.Contains("cursor does not match", secondStderr, StringComparison.Ordinal); + } + + [Fact] + public void RunDeps_CSharpNoiseSuppressionRemovesOnlyNonAuthoritativeQualifiedCalls_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_csharp_cycle_noise_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertFileWithSymbolsAndReferences(dbPath, "src/FallbackA.cs", ["FallbackA"], ["FallbackB"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/FallbackB.cs", ["FallbackB"], ["FallbackA"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/ResolvedA.cs", ["ResolvedA"], ["ResolvedB"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/ResolvedB.cs", ["ResolvedB", "ResolvedB"], ["ResolvedA"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/ResolvedBDecoy.cs", ["ResolvedB"], ["ResolvedA"]); + SetCycleReferenceResolution(dbPath, "src/FallbackA.cs", "src/FallbackB.cs", "unresolved"); + SetCycleReferenceResolution(dbPath, "src/FallbackB.cs", "src/FallbackA.cs", "resolved"); + SetCycleReferenceResolution(dbPath, "src/ResolvedA.cs", "src/ResolvedB.cs", "resolved"); + SetCycleReferenceResolution(dbPath, "src/ResolvedB.cs", "src/ResolvedA.cs", "resolved"); + SetCycleReferenceResolution(dbPath, "src/ResolvedBDecoy.cs", "src/ResolvedA.cs", "resolved"); + MarkDependencyGraphReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--suppress-noise", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var cycle = Assert.Single(json.GetProperty("cycles").EnumerateArray()); + var nodes = cycle.GetProperty("nodes").EnumerateArray().Select(static node => node.GetString()).ToArray(); + var resolutionBreakdown = cycle.GetProperty("retained_evidence").GetProperty("by_resolution_state"); + var resolution = Assert.Single(resolutionBreakdown.EnumerateArray()); + var reason = Assert.Single(json.GetProperty("symbol_filter").GetProperty("suppression_reasons").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(["src/ResolvedA.cs", "src/ResolvedB.cs"], nodes); + Assert.Equal("resolved", resolution.GetProperty("resolution_state").GetString()); + Assert.Equal(2, resolution.GetProperty("reference_count").GetInt64()); + Assert.True(cycle.GetProperty("retained_evidence").GetProperty("classification_complete").GetBoolean()); + Assert.Equal("csharp_non_authoritative_qualified_call", reason.GetProperty("reason").GetString()); + Assert.Equal(2, reason.GetProperty("edges_affected").GetInt32()); + Assert.Equal(2, reason.GetProperty("edges_removed").GetInt32()); + Assert.Equal(2, reason.GetProperty("references_removed").GetInt64()); + } + + [Fact] + public void RunDeps_CSharpNoiseSuppressionKeepsOnlyResolvedGroupCandidateFiles_Issue5197() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_csharp_resolved_group_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertFileWithSymbolsAndReferences(dbPath, "src/Caller.cs", ["Caller"], ["Target"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/PartialDeclaration.cs", ["Target"], ["Caller"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/PartialImplementation.cs", ["Target"], ["Caller"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/Decoy.cs", ["Target"], ["Caller"]); + SetCycleReferenceResolvedGroupCandidates( + dbPath, + "src/Caller.cs", + ["src/PartialDeclaration.cs", "src/PartialImplementation.cs"]); + SetCycleReferenceResolution(dbPath, "src/PartialDeclaration.cs", "src/Caller.cs", "resolved"); + SetCycleReferenceResolution(dbPath, "src/PartialImplementation.cs", "src/Caller.cs", "resolved"); + SetCycleReferenceResolution(dbPath, "src/Decoy.cs", "src/Caller.cs", "resolved"); + MarkDependencyGraphReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--suppress-noise", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var cycle = Assert.Single(json.GetProperty("cycles").EnumerateArray()); + var nodes = cycle.GetProperty("nodes").EnumerateArray().Select(static node => node.GetString()).ToArray(); + var resolutions = cycle + .GetProperty("retained_evidence") + .GetProperty("by_resolution_state") + .EnumerateArray() + .ToDictionary( + static item => item.GetProperty("resolution_state").GetString()!, + static item => item.GetProperty("reference_count").GetInt64(), + StringComparer.Ordinal); + var reason = Assert.Single(json.GetProperty("symbol_filter").GetProperty("suppression_reasons").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal( + ["src/Caller.cs", "src/PartialDeclaration.cs", "src/PartialImplementation.cs"], + nodes); + Assert.Equal(2, resolutions["resolved"]); + Assert.Equal(2, resolutions["resolved_group"]); + Assert.Equal("csharp_non_authoritative_qualified_call", reason.GetProperty("reason").GetString()); + Assert.Equal(1, reason.GetProperty("edges_affected").GetInt32()); + Assert.Equal(1, reason.GetProperty("edges_removed").GetInt32()); + Assert.Equal(1, reason.GetProperty("references_removed").GetInt64()); + } + + [Theory] + [InlineData(null)] + [InlineData("0")] + public void RunDeps_CSharpNoiseSuppressionFailsClosedWithoutCurrentIdentityContract_Issue5197( + string? persistedContractVersion) + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_deps_stale_identity_cycle_5197"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + InsertFileWithSymbolsAndReferences(dbPath, "src/StaleA.cs", ["StaleA"], ["StaleB"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/StaleB.cs", ["StaleB"], ["StaleA"]); + SetCycleReferenceResolution(dbPath, "src/StaleA.cs", "src/StaleB.cs", "unresolved"); + SetCycleReferenceResolution(dbPath, "src/StaleB.cs", "src/StaleA.cs", "unresolved"); + MarkDependencyGraphReady(dbPath); + SetReferenceIdentityContractVersion(dbPath, persistedContractVersion); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--suppress-noise", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var cycle = Assert.Single(json.GetProperty("cycles").EnumerateArray()); + var nodes = cycle.GetProperty("nodes").EnumerateArray().Select(static node => node.GetString()).ToArray(); + var resolution = Assert.Single(cycle + .GetProperty("retained_evidence") + .GetProperty("by_resolution_state") + .EnumerateArray()); + var symbolFilter = json.GetProperty("symbol_filter"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(["src/StaleA.cs", "src/StaleB.cs"], nodes); + Assert.Equal("unavailable", resolution.GetProperty("resolution_state").GetString()); + Assert.Equal(2, resolution.GetProperty("reference_count").GetInt64()); + Assert.Equal(2, symbolFilter.GetProperty("references_before").GetInt64()); + Assert.Equal(2, symbolFilter.GetProperty("references_after").GetInt64()); + Assert.False(symbolFilter.TryGetProperty("suppression_reasons", out _)); + } + + private static void SetReferenceIdentityContractVersion(string dbPath, string? persistedContractVersion) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.ReferenceIdentityContractVersionMetaKey, persistedContractVersion); + } + + private static void InsertDependencyCycle(string dbPath, string prefix, int nodeCount) + { + for (var index = 0; index < nodeCount; index++) + { + InsertFileWithSymbolsAndReferences( + dbPath, + $"src/{prefix}{index:D2}.cs", + [$"{prefix}{index:D2}"], + [$"{prefix}{(index + 1) % nodeCount:D2}"]); + } + } + + private static void SetCycleReferenceResolution( + string dbPath, + string sourcePath, + string targetPath, + string resolutionState) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE symbol_references + SET reference_kind = 'call', + target_qualifier = 'Receiver', + resolution_state = $resolutionState, + resolution_candidate_count = 1, + target_symbol_id = CASE WHEN $resolutionState = 'resolved' THEN ( + SELECT s.id + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.path = $targetPath + AND s.name = symbol_references.symbol_name + ORDER BY s.id + LIMIT 1 + ) ELSE NULL END + WHERE file_id = (SELECT id FROM files WHERE path = $sourcePath) + """; + command.Parameters.AddWithValue("$sourcePath", sourcePath); + command.Parameters.AddWithValue("$targetPath", targetPath); + command.Parameters.AddWithValue("$resolutionState", resolutionState); + Assert.Equal(1, command.ExecuteNonQuery()); + } + + private static void SetCycleReferenceResolvedGroupCandidates( + string dbPath, + string sourcePath, + IReadOnlyList targetPaths) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + connection.Open(); + using var transaction = connection.BeginTransaction(); + using var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandText = """ + UPDATE symbol_references + SET reference_kind = 'call', + target_qualifier = 'Receiver', + resolution_state = 'resolved_group', + resolution_candidate_count = $candidateCount, + target_symbol_id = NULL, + target_symbol_key = 'family:issue5197' + WHERE file_id = (SELECT id FROM files WHERE path = $sourcePath) + """; + update.Parameters.AddWithValue("$candidateCount", targetPaths.Count); + update.Parameters.AddWithValue("$sourcePath", sourcePath); + Assert.Equal(1, update.ExecuteNonQuery()); + + using var delete = connection.CreateCommand(); + delete.Transaction = transaction; + delete.CommandText = """ + DELETE FROM symbol_reference_candidates + WHERE reference_id = ( + SELECT r.id + FROM symbol_references r + JOIN files f ON f.id = r.file_id + WHERE f.path = $sourcePath + ) + """; + delete.Parameters.AddWithValue("$sourcePath", sourcePath); + delete.ExecuteNonQuery(); + + foreach (var targetPath in targetPaths) + { + using var insert = connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = """ + INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) + SELECT r.id, s.id, 0 + FROM symbol_references r + JOIN files source_file ON source_file.id = r.file_id + JOIN files target_file ON target_file.path = $targetPath + JOIN symbols s ON s.file_id = target_file.id AND s.name = r.symbol_name + WHERE source_file.path = $sourcePath + """; + insert.Parameters.AddWithValue("$sourcePath", sourcePath); + insert.Parameters.AddWithValue("$targetPath", targetPath); + Assert.Equal(1, insert.ExecuteNonQuery()); + } + + transaction.Commit(); + } +} diff --git a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json new file mode 100644 index 000000000..0dd2f7687 --- /dev/null +++ b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json @@ -0,0 +1,180 @@ +{ + "count": 1, + "summary_only": true, + "cycle_summaries": [ + { + "rank": 1, + "length": 3, + "node_count": 3, + "internal_edge_count": 3, + "reference_count": 3, + "nodes": [ + "src/CycleA.cs", + "src/CycleB.cs", + "src/CycleC.cs" + ], + "nodes_returned": 3, + "nodes_truncated": false, + "nodes_omitted_count": 0, + "node_limit": 50, + "retained_evidence": { + "retained_reference_count": 3, + "classified_reference_count": 3, + "classification_complete": true, + "by_source_language": [ + { + "source_language": "csharp", + "reference_count": 3 + } + ], + "by_origin": [ + { + "origin": "symbol_name_match", + "reference_count": 3 + } + ], + "by_resolution_state": [ + { + "resolution_state": "resolved", + "reference_count": 3 + } + ], + "by_reference_kind": [ + { + "reference_kind": "type_reference", + "reference_count": 3 + } + ], + "by_target_kind": [ + { + "target_kind": "symbol", + "reference_count": 3 + } + ], + "by_suppression_reason": [ + { + "suppression_reason": "unavailable", + "reference_count": 3 + } + ] + } + } + ], + "truncated": false, + "termination_reason": "completed", + "analysis_complete": true, + "graph_edge_count": 3, + "graph_edge_budget": 10000, + "candidate_edge_count": 3, + "candidate_edge_limit": 10000, + "cycle_detection_mode": "deterministic_scc", + "cycle_ranking_mode": "reference_count_desc_internal_edge_count_desc_length_desc_path", + "cycle_ranking_stable": true, + "cycle_result_scope": "complete_graph", + "cycle_result_note": "SCC analysis and the stable ranked result set are complete for the selected graph.", + "total_cycle_count": 1, + "total_cycle_count_authoritative": true, + "page_offset": 0, + "page_limit": 1, + "returned_count": 1, + "has_more": false, + "next_cursor": null, + "returned_node_count": 3, + "returned_nodes_materialized": 3, + "returned_nodes_omitted_count": 0, + "display_truncated": false, + "display_truncation_reason": null, + "node_materialization_mode": "bounded_sample", + "node_materialization_limit": 50, + "cycle_grouping_mode": "file", + "cycle_grouping_applied": false, + "cycle_grouping_reason": "file_level_scc", + "largest_component": { + "rank": 1, + "length": 3, + "node_count": 3, + "internal_edge_count": 3, + "reference_count": 3, + "nodes": [ + "src/CycleA.cs", + "src/CycleB.cs", + "src/CycleC.cs" + ], + "nodes_returned": 3, + "nodes_truncated": false, + "nodes_omitted_count": 0, + "node_limit": 50, + "retained_evidence": { + "retained_reference_count": 3, + "classified_reference_count": 3, + "classification_complete": true, + "by_source_language": [ + { + "source_language": "csharp", + "reference_count": 3 + } + ], + "by_origin": [ + { + "origin": "symbol_name_match", + "reference_count": 3 + } + ], + "by_resolution_state": [ + { + "resolution_state": "resolved", + "reference_count": 3 + } + ], + "by_reference_kind": [ + { + "reference_kind": "type_reference", + "reference_count": 3 + } + ], + "by_target_kind": [ + { + "target_kind": "symbol", + "reference_count": 3 + } + ], + "by_suppression_reason": [ + { + "suppression_reason": "unavailable", + "reference_count": 3 + } + ] + } + }, + "next_step_flags": [], + "api_version": "1", + "query_context": { + "limit": 1, + "lang": "csharp", + "include_generated": false, + "generated_code_policy": "exclude", + "generated_file_filter_available": true, + "cycles": true, + "graph_budget": 10000, + "all_cycle_nodes": false + }, + "reference_extraction_limits": { + "max_lookup_symbols": 50000, + "max_lookup_lines": 20000, + "max_names_per_line": 512, + "max_container_candidates": 20000 + }, + "reference_graph_complete": true, + "reference_extraction_cap_hits": { + "state_available": true, + "hit_count": 0, + "affected_file_count": 0, + "reasons": [], + "files": [], + "files_truncated": false, + "file_limit": 50 + }, + "indexed_file_count": 3, + "indexed_at": "\u003CTIMESTAMP\u003E", + "freshness_available": true +}