From 2e93b38d8c42e6d87ee7b2f6dea0f66ba565871a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 01:11:16 +0900 Subject: [PATCH 1/8] Make large dependency cycles actionable (#5197) --- DEVELOPER_GUIDE.md | 2 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 8 +- changelog.d/unreleased/5197.changed.md | 34 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 3 +- ...eryCommandRunner.ArgumentParser.Results.cs | 3 + .../Cli/QueryCommandRunner.ArgumentParser.cs | 2 + .../Cli/QueryCommandRunner.Dependencies.cs | 301 ++++++++++++++---- .../Cli/QueryCommandRunner.ResultEnvelopes.cs | 1 + src/CodeIndex/Cli/QueryCommandRunner.cs | 2 + .../DbReader.DependencyCycleCandidateSql.cs | 7 +- .../DbReader.DependencyCycleEvidenceSql.cs | 20 +- .../Database/DbReader.DependencyCyclePlan.cs | 30 +- .../Database/DbReader.DependencyProjection.cs | 10 +- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 3 + .../Mcp/McpToolHandlers.ArgumentValidation.cs | 2 +- .../Mcp/McpToolHandlers.Graph.Dependencies.cs | 41 ++- src/CodeIndex/Models/QueryResults.cs | 3 + .../McpServerIssue5197Tests.cs | 116 +++++++ .../QueryCommandRunnerIssue5197Tests.cs | 143 +++++++++ 21 files changed, 649 insertions(+), 86 deletions(-) create mode 100644 changelog.d/unreleased/5197.changed.md create mode 100644 tests/CodeIndex.Tests/McpServerIssue5197Tests.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 87722ec8b..154ba75ce 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,6 +2746,7 @@ 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, expose omitted-node and display-truncation metadata, support compact summaries, and require explicit `--all-cycle-nodes` / `includeAllCycleNodes` opt-in for raw expansion. Noise suppression classifies evidence before candidate budgeting and drops only legacy Markdown heading matches, unresolved or ambiguous qualified C# calls, and resolved-call name matches whose symbol differs from the confirmed `target_symbol_id`; confirmed resolved targets and resolved partial-family groups are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#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. @@ -6904,6 +6905,7 @@ 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 し、省略 node 数と表示 truncation metadata を公開し、compact summary を提供します。raw 展開には `--all-cycle-nodes` / `includeAllCycleNodes` の明示 opt-in が必要です。noise 抑制は candidate budget より前に evidence を分類し、旧 Markdown 見出し一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` と異なる解決済み call の同名一致だけを除外します。確認済みの解決済みtargetと解決済みpartial-family groupは保持します。grouping metadata は file 単位で、partial type family の collapse を適用していないことを明示します(#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 6c3291a47..6a26eec16 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` and `McpServerIssue5197Tests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization, compact summaries, explicit raw expansion, largest-component/grouping metadata, and CLI/MCP parity. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence, while confirmed resolved targets must remain and drive the surviving SCC. - 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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1160,6 +1161,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs` と `McpServerIssue5197Tests.cs` が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、既定の上限付き materialization、compact summary、明示的な raw 展開、最大 component / grouping metadata、CLI / MCP parity を検証してください。C# 抑制 fixture は、未解決の修飾 call と解決済みcallの同名decoyが除外可能な evidence である一方、確認済みの解決済みtargetは保持され、残る SCC を構成する混在状態を維持します。 - 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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e4265239c..7db5990bf 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2421,8 +2421,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. `--suppress-noise` removes only classified evidence: legacy Markdown heading-name matches, unresolved or ambiguous qualified C# calls, and non-target name matches of resolved qualified C# calls; the confirmed `target_symbol_id` edge and explicit Markdown links remain visible. JSON evidence exposes `source_language`, `origin`, `resolution_state`, `reference_kind`, `target_kind`, `suppression_reason`, and `reference_count`; `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; `node_count`, `nodes_returned`, `nodes_omitted_count`, `display_truncated`, `largest_component`, evidence breakdowns, and file-level grouping metadata remain available independently of `analysis_complete`. Use `--summary-only` for compact `cycle_summaries`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node expansion. 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` | @@ -6050,8 +6050,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 内で適用される。`--suppress-noise` が除外するのは、旧 index 由来の Markdown 見出し名一致、未解決または曖昧な C# 修飾 call、および解決済み C# 修飾 call が同名の非targetへ作る name match だけであり、確認済み `target_symbol_id` edge と明示的な Markdown link は残る。JSON evidence は `source_language`、`origin`、`resolution_state`、`reference_kind`、`target_kind`、`suppression_reason`、`reference_count` を公開し、`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 は既定で最大 50 件だけ materialize するが、`node_count`、`nodes_returned`、`nodes_omitted_count`、`display_truncated`、`largest_component`、evidence breakdown、file 単位の grouping metadata は `analysis_complete` と独立して返す。簡潔な `cycle_summaries` には `--summary-only`、明示的な raw node 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。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..714f12af0 --- /dev/null +++ b/changelog.d/unreleased/5197.changed.md @@ -0,0 +1,34 @@ +--- +category: changed +issues: + - 5197 +affected: + - 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/Models/QueryResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs + - tests/CodeIndex.Tests/McpServerIssue5197Tests.cs + - 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, evidence breakdowns, largest-component details, and file-level grouping metadata while materializing only 50 nodes per component by default. Compact summaries and explicit raw expansion are available, and C# noise suppression removes unresolved, ambiguous, or resolved same-name non-target candidates while preserving confirmed targets. + +## 日本語 + +- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、file 単位の grouping metadata を保持しつつ、各 component の node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は未解決、曖昧、または解決済みcallの同名非target candidateを除外して、確認済みtargetを保持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index ce0207d3e..000eae2bf 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..236a175c9 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); @@ -901,7 +924,12 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C options, sqlGraphSignal, symbolFilter.Summary, - dependencyCycleAnalysis == null ? null : payload => AddDependencyCycleAnalysisJsonFields(payload, dependencyCycleAnalysis)); + dependencyCycleAnalysis == null + ? null + : payload => AddDependencyCycleAnalysisJsonFields( + payload, + dependencyCycleAnalysis, + includeAllNodes: options.IncludeAllDependencyCycleNodes)); if ((depsFormat is OutputFormatDot or OutputFormatGraphMl) && dependencyCycleAnalysis is { Truncated: true }) CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning(dependencyCycleAnalysis)); return writeExitCode; @@ -917,12 +945,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); @@ -1146,6 +1184,10 @@ 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 evidenceByResolutionState = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); + var evidenceByReferenceKind = CreateDependencyCycleEvidenceMaps(cycleNodes.Count); foreach (var edge in edges) { cancellationToken.ThrowIfCancellationRequested(); @@ -1155,13 +1197,25 @@ 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(evidenceByResolutionState[sourceComponent], evidence.ResolutionState, evidence.ReferenceCount); + AddDependencyCycleEvidenceCount(evidenceByReferenceKind[sourceComponent], evidence.ReferenceKind, evidence.ReferenceCount); + } } return cycleNodes .Select((component, componentIndex) => new DependencyCycleComponent( component, internalEdgeCounts[componentIndex], - referenceCounts[componentIndex])) + referenceCounts[componentIndex], + new DependencyCycleEvidenceBreakdown( + classifiedReferenceCounts[componentIndex], + ToDependencyCycleEvidenceCounts(evidenceByLanguage[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByResolutionState[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByReferenceKind[componentIndex])))) .OrderByDescending(static component => component.ReferenceCount) .ThenByDescending(static component => component.InternalEdgeCount) .ThenByDescending(static component => component.Nodes.Count) @@ -1169,6 +1223,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) @@ -1188,24 +1258,71 @@ private static List FilterEdgesToComponents( 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_resolution_state"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByResolutionState, "resolution_state"), + ["by_reference_kind"] = BuildDependencyCycleEvidenceCountsJson(evidence.ByReferenceKind, "reference_kind"), + }; } + 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 +1331,19 @@ 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 ByResolutionState, + IReadOnlyList ByReferenceKind) + { + internal static readonly DependencyCycleEvidenceBreakdown Empty = new(0, [], [], []); + } internal sealed record DependencyCycleAnalysis( List Edges, @@ -1235,6 +1364,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 +1399,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 +1422,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,9 +1456,29 @@ 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 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) + => analysis.Components.Any(static component => component.Nodes.Count > DefaultDependencyCycleNodeLimit) + || analysis.LargestComponent is { Nodes.Count: > DefaultDependencyCycleNodeLimit }; + private static string BuildDependencyCycleTruncationSummary(DependencyCycleAnalysis analysis) => analysis.TruncatedReason == "page_limit" ? $"page complete: showing ranked cycles {analysis.PageOffset + 1}-{analysis.PageOffset + analysis.Components.Count}" @@ -1320,7 +1486,7 @@ private static string BuildDependencyCycleTruncationSummary(DependencyCycleAnaly private static string BuildDependencyCycleTruncationWarning(DependencyCycleAnalysis analysis) { - var nextSteps = BuildDependencyCycleNextStepFlags(analysis, mcpArguments: false); + var nextSteps = BuildDependencyCycleNextStepFlags(analysis, mcpArguments: false, includeAllNodes: false); var nextStepsText = nextSteps.Count == 0 ? string.Empty : $" Next steps: {string.Join(", ", nextSteps)}."; @@ -1348,16 +1514,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") @@ -1495,7 +1668,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 +1696,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 +1707,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 +1751,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 +1772,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 +1855,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 +1901,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 +1935,7 @@ private sealed record DependencySymbolFilterSummary( long ReferencesAfter, IReadOnlyList SuppressionReasons); - private sealed record DependencySuppressionReasonSummary( + internal sealed record DependencySuppressionReasonSummary( string Reason, int EdgesAffected, int EdgesRemoved, @@ -2052,7 +2235,7 @@ internal static bool AddBoundedWorkspaceCycleCandidates( return false; } - private static List OrderWorkspaceCycleCandidates( + internal static List OrderWorkspaceCycleCandidates( IEnumerable results, int limit) => results @@ -2066,9 +2249,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..c9311a61e 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) { @@ -33,16 +36,35 @@ private DependencyCycleQueryExpressions BuildDependencyCycleQueryExpressions(Dep 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 = _referenceColumns.Contains("target_symbol_id") + ? "(r.resolution_state = 'resolved' AND r.target_symbol_id IS NOT NULL AND r.target_symbol_id <> s.id)" + : "0 = 1"; + var csharpNonAuthoritativeQualifiedCall = _referenceColumns.Contains("target_qualifier") + && _referenceColumns.Contains("resolution_state") + ? "(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 + + "))" + : "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", + _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..a42dd7af1 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. / 通常の cycles collection を省略し、上限付き SCC metrics と node sample を返す。", ["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..f964c1dae 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs @@ -34,12 +34,17 @@ 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 (cursorValue != null && !QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out _)) return CreateToolErrorResponse(id, "'cursor' must be an opaque dependency-cycle next_cursor returned by deps."); @@ -51,6 +56,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 +79,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, @@ -111,13 +129,28 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) lang); var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; if (cyclesOnly) - payload["cycles"] = QueryCommandRunner.BuildDependencyCyclesJson(cycleAnalysis!.Components, cycleAnalysis.PageOffset); + { + 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; 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/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs new file mode 100644 index 000000000..ea9b22b52 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -0,0 +1,116 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; + +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 expandedRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"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()); + } + + [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"]); + 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(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..b1b693396 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -0,0 +1,143 @@ +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()); + } + + [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"], ["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()); + } + + 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()); + } +} From d3409ec9f7cd78ecc57e148f09e550591dc37916 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 02:08:45 +0900 Subject: [PATCH 2/8] Address dependency cycle review findings (#5197) --- DEVELOPER_GUIDE.md | 4 ++-- TESTING_GUIDE.md | 4 ++-- USER_GUIDE.md | 4 ++-- .../Cli/QueryCommandRunner.Dependencies.cs | 15 ++++++++++++++- .../Database/DbReader.DependencyCyclePlan.cs | 2 +- .../CodeIndex.Tests/McpServerIssue5197Tests.cs | 3 ++- .../QueryCommandRunnerIssue5197Tests.cs | 18 +++++++++++++++++- 7 files changed, 40 insertions(+), 10 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 154ba75ce..d3ce5e029 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,7 +2746,7 @@ 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, expose omitted-node and display-truncation metadata, support compact summaries, and require explicit `--all-cycle-nodes` / `includeAllCycleNodes` opt-in for raw expansion. Noise suppression classifies evidence before candidate budgeting and drops only legacy Markdown heading matches, unresolved or ambiguous qualified C# calls, and resolved-call name matches whose symbol differs from the confirmed `target_symbol_id`; confirmed resolved targets and resolved partial-family groups are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **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, expose omitted-node and display-truncation metadata, support compact summaries, and require explicit `--all-cycle-nodes` / `includeAllCycleNodes` opt-in for raw expansion. Noise suppression classifies evidence before candidate budgeting and drops only legacy Markdown heading matches, unresolved or ambiguous qualified C# calls, and resolved-call name matches in files that do not contain the confirmed `target_symbol_id`; the confirmed target file takes authoritative precedence even when it contains same-name overloads, and resolved partial-family groups are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#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. @@ -6905,7 +6905,7 @@ 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 し、省略 node 数と表示 truncation metadata を公開し、compact summary を提供します。raw 展開には `--all-cycle-nodes` / `includeAllCycleNodes` の明示 opt-in が必要です。noise 抑制は candidate budget より前に evidence を分類し、旧 Markdown 見出し一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` と異なる解決済み call の同名一致だけを除外します。確認済みの解決済みtargetと解決済みpartial-family groupは保持します。grouping metadata は file 単位で、partial type family の collapse を適用していないことを明示します(#5197)。 +- **大規模な依存 SCC は事実ではなく表示だけを制限する** — SCC の解析と順位付けは、node 表示とは独立して authoritative な component size、edge / reference 合計、最大 component summary、evidence breakdown を保持します。CLI / MCP は各返却 component について path 順 node を既定で 50 件だけ materialize し、省略 node 数と表示 truncation metadata を公開し、compact summary を提供します。raw 展開には `--all-cycle-nodes` / `includeAllCycleNodes` の明示 opt-in が必要です。noise 抑制は candidate budget より前に evidence を分類し、旧 Markdown 見出し一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` を含まない file にある解決済み call の同名一致だけを除外します。同名 overload がある場合も確認済み target の file を authoritative に優先し、解決済みpartial-family groupも保持します。grouping metadata は file 単位で、partial type family の collapse を適用していないことを明示します(#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 0b26ea9b3..a5b787874 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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` and `McpServerIssue5197Tests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization, compact summaries, explicit raw expansion, largest-component/grouping metadata, and CLI/MCP parity. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence, while confirmed resolved targets must remain and drive the surviving SCC. +- Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs` and `McpServerIssue5197Tests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in JSON and human output, compact summaries, explicit raw expansion, largest-component/grouping metadata, and CLI/MCP parity. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence, while confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - 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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1162,7 +1162,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs` と `McpServerIssue5197Tests.cs` が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、既定の上限付き materialization、compact summary、明示的な raw 展開、最大 component / grouping metadata、CLI / MCP parity を検証してください。C# 抑制 fixture は、未解決の修飾 call と解決済みcallの同名decoyが除外可能な evidence である一方、確認済みの解決済みtargetは保持され、残る SCC を構成する混在状態を維持します。 +- Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs` と `McpServerIssue5197Tests.cs` が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、JSON / human 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、最大 component / grouping metadata、CLI / MCP parity を検証してください。C# 抑制 fixture は、未解決の修飾 call と解決済みcallの同名decoyが除外可能な evidence である一方、target file に同名 overload がある場合も確認済みの解決済みtargetが保持され、残る SCC を構成する混在状態を維持します。 - 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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7db5990bf..5fe0f5487 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2421,7 +2421,7 @@ 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` removes only classified evidence: legacy Markdown heading-name matches, unresolved or ambiguous qualified C# calls, and non-target name matches of resolved qualified C# calls; the confirmed `target_symbol_id` edge and explicit Markdown links remain visible. JSON evidence exposes `source_language`, `origin`, `resolution_state`, `reference_kind`, `target_kind`, `suppression_reason`, and `reference_count`; `symbol_filter` reports reference before/after totals and per-reason affected/removed counts. | +| `--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` 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`; the confirmed target file remains visible even with same-name overloads, as do explicit Markdown links. JSON evidence exposes `source_language`, `origin`, `resolution_state`, `reference_kind`, `target_kind`, `suppression_reason`, and `reference_count`; `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; `node_count`, `nodes_returned`, `nodes_omitted_count`, `display_truncated`, `largest_component`, evidence breakdowns, and file-level grouping metadata remain available independently of `analysis_complete`. Use `--summary-only` for compact `cycle_summaries`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node expansion. 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` | @@ -6050,7 +6050,7 @@ 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 見出し名一致、未解決または曖昧な C# 修飾 call、および解決済み C# 修飾 call が同名の非targetへ作る name match だけであり、確認済み `target_symbol_id` edge と明示的な Markdown link は残る。JSON evidence は `source_language`、`origin`、`resolution_state`、`reference_kind`、`target_kind`、`suppression_reason`、`reference_count` を公開し、`symbol_filter` は reference の before/after 合計と理由別の affected / removed 件数を返す。 | +| `--symbol ` / `--symbol-family ` / `--suppress-noise` | `deps` | 完全一致のシンボル、シンボル名の接頭辞、または組み込み noise profile で依存 edge を絞り込む。完全一致 / 接頭辞 filter と汎用 symbol 抑制は、cycle と cross-workspace query を含め、候補の ranking と `--limit` より前に SQLite 内で適用される。`--suppress-noise` が除外するのは、旧 index 由来の Markdown 見出し名一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` を含まない file に解決済み C# 修飾 call が作る同名一致だけであり、同名 overload があっても確認済み target の file と明示的な Markdown link は残る。JSON evidence は `source_language`、`origin`、`resolution_state`、`reference_kind`、`target_kind`、`suppression_reason`、`reference_count` を公開し、`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 は既定で最大 50 件だけ materialize するが、`node_count`、`nodes_returned`、`nodes_omitted_count`、`display_truncated`、`largest_component`、evidence breakdown、file 単位の grouping metadata は `analysis_complete` と独立して返す。簡潔な `cycle_summaries` には `--summary-only`、明示的な raw node 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。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` を返す。 | diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 236a175c9..514481e8c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -974,7 +974,7 @@ 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; @@ -997,6 +997,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.", diff --git a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs index c9311a61e..bab7508c4 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs @@ -37,7 +37,7 @@ private DependencyCycleQueryExpressions BuildDependencyCycleQueryExpressions(Dep ? "(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 = _referenceColumns.Contains("target_symbol_id") - ? "(r.resolution_state = 'resolved' AND r.target_symbol_id IS NOT NULL AND r.target_symbol_id <> s.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 csharpNonAuthoritativeQualifiedCall = _referenceColumns.Contains("target_qualifier") && _referenceColumns.Contains("resolution_state") diff --git a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs index ea9b22b52..af93dfc22 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -64,7 +64,7 @@ public void ToolsCall_DepsNoiseSuppressionPreservesResolvedCSharpCalls_Issue5197 InsertDependencyReferences(writer, fallbackBId, ["McpFallbackA"]); InsertDependencySymbols(writer, resolvedAId, ["McpResolvedA"]); InsertDependencyReferences(writer, resolvedAId, ["McpResolvedB"]); - InsertDependencySymbols(writer, resolvedBId, ["McpResolvedB"]); + InsertDependencySymbols(writer, resolvedBId, ["McpResolvedB", "McpResolvedB"]); InsertDependencyReferences(writer, resolvedBId, ["McpResolvedA"]); InsertDependencySymbols(writer, resolvedBDecoyId, ["McpResolvedB"]); InsertDependencyReferences(writer, resolvedBDecoyId, ["McpResolvedA"]); @@ -85,6 +85,7 @@ public void ToolsCall_DepsNoiseSuppressionPreservesResolvedCSharpCalls_Issue5197 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()); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index b1b693396..225fb080d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -66,6 +66,22 @@ public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Is 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); } [Fact] @@ -76,7 +92,7 @@ public void RunDeps_CSharpNoiseSuppressionRemovesOnlyNonAuthoritativeQualifiedCa 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"], ["ResolvedA"]); + 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"); From 2eb9ee4ab5e66ce50c124e0367c2c853d35b3688 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 03:11:06 +0900 Subject: [PATCH 3/8] Complete dependency cycle review fixes (#5197) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 +- changelog.d/unreleased/5197.changed.md | 6 +- .../Cli/QueryCommandRunner.Dependencies.cs | 128 +++++++++++++--- .../Database/DbReader.DependencyCyclePlan.cs | 10 +- .../JsonOutputSnapshotTests.cs | 70 +++++++++ .../QueryCommandRunnerIssue5197Tests.cs | 96 ++++++++++++ .../golden/deps-cycles-summary.json | 141 ++++++++++++++++++ 9 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 tests/CodeIndex.Tests/golden/deps-cycles-summary.json diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d3ce5e029..ef0df1866 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,7 +2746,7 @@ 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, expose omitted-node and display-truncation metadata, support compact summaries, and require explicit `--all-cycle-nodes` / `includeAllCycleNodes` opt-in for raw expansion. Noise suppression classifies evidence before candidate budgeting and drops only legacy Markdown heading matches, unresolved or ambiguous qualified C# calls, and resolved-call name matches in files that do not contain the confirmed `target_symbol_id`; the confirmed target file takes authoritative precedence even when it contains same-name overloads, and resolved partial-family groups are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **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. 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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#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. @@ -6905,7 +6905,7 @@ 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 し、省略 node 数と表示 truncation metadata を公開し、compact summary を提供します。raw 展開には `--all-cycle-nodes` / `includeAllCycleNodes` の明示 opt-in が必要です。noise 抑制は candidate budget より前に evidence を分類し、旧 Markdown 見出し一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` を含まない file にある解決済み call の同名一致だけを除外します。同名 overload がある場合も確認済み target の file を authoritative に優先し、解決済みpartial-family groupも保持します。grouping metadata は file 単位で、partial type family の collapse を適用していないことを明示します(#5197)。 +- **大規模な依存 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 が必要です。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)。 - **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 a5b787874..1e9fdc090 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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` and `McpServerIssue5197Tests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in JSON and human output, compact summaries, explicit raw expansion, largest-component/grouping metadata, and CLI/MCP parity. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence, while confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. +- 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. 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 #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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1162,7 +1162,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs` と `McpServerIssue5197Tests.cs` が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、JSON / human 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、最大 component / grouping metadata、CLI / MCP parity を検証してください。C# 抑制 fixture は、未解決の修飾 call と解決済みcallの同名decoyが除外可能な evidence である一方、target file に同名 overload がある場合も確認済みの解決済みtargetが保持され、残る SCC を構成する混在状態を維持します。 +- 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 を検証してください。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - 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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5fe0f5487..2edc6deff 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2421,8 +2421,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` 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`; the confirmed target file remains visible even with same-name overloads, as do explicit Markdown links. JSON evidence exposes `source_language`, `origin`, `resolution_state`, `reference_kind`, `target_kind`, `suppression_reason`, and `reference_count`; `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; `node_count`, `nodes_returned`, `nodes_omitted_count`, `display_truncated`, `largest_component`, evidence breakdowns, and file-level grouping metadata remain available independently of `analysis_complete`. Use `--summary-only` for compact `cycle_summaries`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node expansion. 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`; `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`. Use `--summary-only` for compact `cycle_summaries`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node and graph expansion. 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` | @@ -6050,8 +6050,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 見出し名一致、未解決または曖昧な C# 修飾 call、および確認済み `target_symbol_id` を含まない file に解決済み C# 修飾 call が作る同名一致だけであり、同名 overload があっても確認済み target の file と明示的な Markdown link は残る。JSON evidence は `source_language`、`origin`、`resolution_state`、`reference_kind`、`target_kind`、`suppression_reason`、`reference_count` を公開し、`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 は既定で最大 50 件だけ materialize するが、`node_count`、`nodes_returned`、`nodes_omitted_count`、`display_truncated`、`largest_component`、evidence breakdown、file 単位の grouping metadata は `analysis_complete` と独立して返す。簡潔な `cycle_summaries` には `--summary-only`、明示的な raw node 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。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` を公開し、`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` と独立して返す。簡潔な `cycle_summaries` には `--summary-only`、明示的な raw node / graph 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。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 index 714f12af0..c517942ff 100644 --- a/changelog.d/unreleased/5197.changed.md +++ b/changelog.d/unreleased/5197.changed.md @@ -20,6 +20,8 @@ affected: - 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 @@ -27,8 +29,8 @@ affected: ## English -- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, evidence breakdowns, largest-component details, and file-level grouping metadata while materializing only 50 nodes per component by default. Compact summaries and explicit raw expansion are available, and C# noise suppression removes unresolved, ambiguous, or resolved same-name non-target candidates while preserving confirmed targets. +- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, evidence breakdowns, largest-component details, 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 explicit raw expansion are available, 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 and failing closed for stale or absent identity contracts. ## 日本語 -- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、file 単位の grouping metadata を保持しつつ、各 component の node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は未解決、曖昧、または解決済みcallの同名非target candidateを除外して、確認済みtargetを保持します。 +- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、file 単位の grouping metadata を保持しつつ、human、JSON、DOT、GraphML、JSON graph の各出力で component ごとの node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに stale / absent identity contract では fail closed します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 514481e8c..58139c4a3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -907,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); @@ -915,9 +917,20 @@ 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, @@ -929,9 +942,23 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C : payload => AddDependencyCycleAnalysisJsonFields( payload, dependencyCycleAnalysis, - includeAllNodes: options.IncludeAllDependencyCycleNodes)); - if ((depsFormat is OutputFormatDot or OutputFormatGraphMl) && dependencyCycleAnalysis is { Truncated: true }) - CommandErrorWriter.WriteStderr(BuildDependencyCycleTruncationWarning(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; } @@ -980,7 +1007,9 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions, C : 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; } @@ -1269,6 +1298,28 @@ private static List FilterEdgesToComponents( .ToList(); } + private sealed record DependencyCycleGraphProjection( + List Edges, + IReadOnlyList Nodes); + + private 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, @@ -1489,17 +1540,35 @@ internal static void AddDependencyCycleAnalysisJsonFields( } private static bool HasTruncatedDependencyCycleNodeDisplay(DependencyCycleAnalysis analysis) - => analysis.Components.Any(static component => component.Nodes.Count > DefaultDependencyCycleNodeLimit) + => 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) => analysis.TruncatedReason == "page_limit" ? $"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, includeAllNodes: false); + var nextSteps = BuildDependencyCycleNextStepFlags(analysis, mcpArguments: false, includeAllNodes); var nextStepsText = nextSteps.Count == 0 ? string.Empty : $" Next steps: {string.Join(", ", nextSteps)}."; @@ -1962,12 +2031,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("}"); @@ -1975,14 +2048,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; } @@ -1995,16 +2077,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 }; diff --git a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs index bab7508c4..660990a9e 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs @@ -33,14 +33,18 @@ 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 = _referenceColumns.Contains("target_symbol_id") + 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 csharpNonAuthoritativeQualifiedCall = _referenceColumns.Contains("target_qualifier") + 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 + "))" @@ -62,7 +66,7 @@ private DependencyCycleQueryExpressions BuildDependencyCycleQueryExpressions(Dep ? " WHERE suppression_reason IS NULL" : string.Empty, request.Reverse ? "dst" : "src", - _referenceColumns.Contains("resolution_state") + hasCurrentReferenceIdentityContract && _referenceColumns.Contains("resolution_state") ? "COALESCE(r.resolution_state, 'unavailable')" : "'unavailable'"); } 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/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index 225fb080d..87e433d16 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -82,6 +82,56 @@ public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Is 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()); + + 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] @@ -125,6 +175,52 @@ public void RunDeps_CSharpNoiseSuppressionRemovesOnlyNonAuthoritativeQualifiedCa Assert.Equal(2, 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 SetCycleReferenceResolution( string dbPath, string sourcePath, 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..a44e3f4e6 --- /dev/null +++ b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json @@ -0,0 +1,141 @@ +{ + "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_resolution_state": [ + { + "resolution_state": "resolved", + "reference_count": 3 + } + ], + "by_reference_kind": [ + { + "reference_kind": "type_reference", + "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, + "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_resolution_state": [ + { + "resolution_state": "resolved", + "reference_count": 3 + } + ], + "by_reference_kind": [ + { + "reference_kind": "type_reference", + "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 +} From c0ce18bdee1a7b5897b85544bdd0f1d491e7118b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 03:35:23 +0900 Subject: [PATCH 4/8] Close dependency cycle review gaps (#5197) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 4 +- changelog.d/unreleased/5197.changed.md | 6 +- src/CodeIndex/Cli/CliContractManifest.cs | 1 + .../Cli/QueryCommandRunner.Dependencies.cs | 9 +++ src/CodeIndex/Mcp/McpToolOutputSchemas.cs | 2 + .../McpServerIssue5197Tests.cs | 7 ++- .../QueryCommandRunnerIssue5197Tests.cs | 59 +++++++++++++++++++ .../golden/deps-cycles-summary.json | 3 + 10 files changed, 90 insertions(+), 9 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ef0df1866..f7838223e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,7 +2746,7 @@ 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. 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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **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. 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. 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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#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. @@ -6905,7 +6905,7 @@ 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 が必要です。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)。 +- **大規模な依存 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 が必要です。machine-readable metadata は current な返却 SCC page の node 合計、materialize 済み件数、省略件数を global な最大 component summary とは別に集計するため、後続 cursor page でも authoritative な件数を維持します。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)。 - **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 1e9fdc090..60ddd1de8 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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. 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 #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, and the actual MCP `cycle_summaries` result must validate against the advertised `deps` output schema. 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 #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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1162,7 +1162,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 を検証してください。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 +- 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 数を報告し、実際の MCP `cycle_summaries` 結果が公開された `deps` output schema に適合することを検証します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - 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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2edc6deff..be5e3e46e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2422,7 +2422,7 @@ same source location. | `--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. 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`; `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`. Use `--summary-only` for compact `cycle_summaries`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node and graph expansion. 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`. | +| `--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`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node and graph expansion. 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` | @@ -6051,7 +6051,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--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 内で適用される。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` を公開し、`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` と独立して返す。簡潔な `cycle_summaries` には `--summary-only`、明示的な raw node / graph 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。graph budget 枯渇時は SCC 集合と総件数が non-authoritative であることを明示するため、`--graph-budget` を増やすか、`--suppress-noise`、`--symbol`、`--symbol-family`、`--path` で graph を絞り込む。 | +| `--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`、明示的な raw node / graph 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。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 index c517942ff..5cadb7da8 100644 --- a/changelog.d/unreleased/5197.changed.md +++ b/changelog.d/unreleased/5197.changed.md @@ -3,6 +3,7 @@ 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 @@ -17,6 +18,7 @@ affected: - 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 @@ -29,8 +31,8 @@ affected: ## English -- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, evidence breakdowns, largest-component details, 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 explicit raw expansion are available, 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 and failing closed for stale or absent identity contracts. +- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, evidence breakdowns, 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 explicit raw expansion are available, 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 and failing closed for stale or absent identity contracts. ## 日本語 -- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、file 単位の grouping metadata を保持しつつ、human、JSON、DOT、GraphML、JSON graph の各出力で component ごとの node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに stale / absent identity contract では fail closed します。 +- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、current page の省略合計、file 単位の grouping metadata を保持しつつ、human、JSON、DOT、GraphML、JSON graph の各出力で component ごとの node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに 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/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 58139c4a3..e605dda2c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -1520,6 +1520,15 @@ internal static void AddDependencyCycleAnalysisJsonFields( payload["returned_count"] = analysis.Components.Count; payload["has_more"] = analysis.HasMore; payload["next_cursor"] = analysis.NextCursor; + 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; diff --git a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs index bfadc841e..ad88f7447 100644 --- a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs +++ b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs @@ -177,6 +177,7 @@ private static JsonArray RequiredToolProperties(string toolName) RequiredSchema("edges"), RequiredSchema("graph"), RequiredSchema("cycles"), + RequiredSchema("cycle_summaries"), }, _ => null, }; @@ -396,6 +397,7 @@ private static JsonObject DependencyProperties() { ["edges"] = Reference("rows"), ["cycles"] = Reference("rows"), + ["cycle_summaries"] = Reference("rows"), ["graph"] = new JsonObject { ["type"] = "object", diff --git a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs index af93dfc22..855bdd4da 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -38,8 +38,13 @@ public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_I 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 expandedRequest = JsonNode.Parse( - """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"includeAllCycleNodes":true,"limit":1,"lang":"csharp"}}}""")!; + """{"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())!; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index 87e433d16..601f43d18 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -102,6 +102,11 @@ public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Is 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"], @@ -134,6 +139,48 @@ public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Is Assert.DoesNotContain("--all-cycle-nodes", budgetStderr); } + [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_CSharpNoiseSuppressionRemovesOnlyNonAuthoritativeQualifiedCalls_Issue5197() { @@ -221,6 +268,18 @@ private static void SetReferenceIdentityContractVersion(string dbPath, string? p 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, diff --git a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json index a44e3f4e6..b1aba5b3a 100644 --- a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json +++ b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json @@ -61,6 +61,9 @@ "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", From 2e2c481cbed8be753a256fcd4ff177a3ed99e807 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 03:55:53 +0900 Subject: [PATCH 5/8] Align MCP cycle summary format validation (#5197) --- TESTING_GUIDE.md | 4 ++-- USER_GUIDE.md | 4 ++-- src/CodeIndex/Mcp/McpToolCatalog.cs | 2 +- .../Mcp/McpToolHandlers.Graph.Dependencies.cs | 2 ++ tests/CodeIndex.Tests/McpServerIssue5197Tests.cs | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 60ddd1de8..3843cef04 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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, and the actual MCP `cycle_summaries` result must validate against the advertised `deps` output schema. 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 #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, the actual MCP `cycle_summaries` result must validate against the advertised `deps` output schema, and 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 #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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1162,7 +1162,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 数を報告し、実際の MCP `cycle_summaries` 結果が公開された `deps` output schema に適合することを検証します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 +- 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 数を報告し、実際の MCP `cycle_summaries` 結果が公開された `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 #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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index be5e3e46e..ccbac3b4a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2422,7 +2422,7 @@ same source location. | `--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. 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`; `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`, or `--all-cycle-nodes` (MCP: `includeAllCycleNodes: true`) for explicit raw node and graph expansion. 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`. | +| `--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. 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` | @@ -6051,7 +6051,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--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 内で適用される。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` を公開し、`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`、明示的な raw node / graph 展開には `--all-cycle-nodes`(MCP: `includeAllCycleNodes: true`)を使う。graph budget 枯渇時は SCC 集合と総件数が non-authoritative であることを明示するため、`--graph-budget` を増やすか、`--suppress-noise`、`--symbol`、`--symbol-family`、`--path` で graph を絞り込む。 | +| `--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`)を使う。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/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index a42dd7af1..7510962fb 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -608,7 +608,7 @@ private static JsonObject[] CreateWorkspaceInspectionTools() ["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. / 通常の cycles collection を省略し、上限付き SCC metrics と node sample を返す。", ["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.Graph.Dependencies.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs index f964c1dae..2bb899008 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs @@ -45,6 +45,8 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) 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."); diff --git a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs index 855bdd4da..fac13354b 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -54,6 +54,21 @@ public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_I Assert.False(expanded["display_truncated"]!.GetValue()); } + [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() { From 53ac8e89ab0b988f6c905eec1c1af1eaa4123e58 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 04:26:58 +0900 Subject: [PATCH 6/8] Complete dependency cycle evidence contracts (#5197) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 ++-- changelog.d/unreleased/5197.changed.md | 4 +- .../Cli/QueryCommandRunner.Dependencies.cs | 21 ++++++++-- src/CodeIndex/Mcp/McpToolOutputSchemas.cs | 28 +++++++++++-- .../McpServerIssue5197Tests.cs | 16 ++++++++ .../QueryCommandRunnerIssue5197Tests.cs | 39 +++++++++++++++++++ .../golden/deps-cycles-summary.json | 36 +++++++++++++++++ 9 files changed, 144 insertions(+), 16 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f7838223e..6673b426e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,7 +2746,7 @@ 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. 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. 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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#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. @@ -6905,7 +6905,7 @@ 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 が必要です。machine-readable metadata は current な返却 SCC page の node 合計、materialize 済み件数、省略件数を global な最大 component summary とは別に集計するため、後続 cursor page でも authoritative な件数を維持します。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)。 +- **大規模な依存 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)。 - **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 3843cef04..6738245a9 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +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, the actual MCP `cycle_summaries` result must validate against the advertised `deps` output schema, and 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 #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 #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. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. @@ -1162,7 +1162,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 数を報告し、実際の MCP `cycle_summaries` 結果が公開された `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 #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 #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 を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ccbac3b4a..4d0bdf0dd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2421,8 +2421,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. 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`; `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. 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` | @@ -6050,8 +6050,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 内で適用される。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` を公開し、`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`)を使う。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 index 5cadb7da8..0942e8793 100644 --- a/changelog.d/unreleased/5197.changed.md +++ b/changelog.d/unreleased/5197.changed.md @@ -31,8 +31,8 @@ affected: ## English -- **Large dependency cycles are now bounded and actionable (#5197)** — CLI and MCP dependency-cycle results retain authoritative SCC sizes, totals, evidence breakdowns, 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 explicit raw expansion are available, 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 and failing closed for stale or absent identity contracts. +- **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, 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 and failing closed for stale or absent identity contracts. ## 日本語 -- **大規模な依存 cycle が上限付きで実用的になりました (#5197)** — CLI / MCP の dependency-cycle 結果は authoritative な SCC size、合計、evidence breakdown、最大 component の詳細、current page の省略合計、file 単位の grouping metadata を保持しつつ、human、JSON、DOT、GraphML、JSON graph の各出力で component ごとの node materialization を既定で 50 件に制限します。compact summary と明示的な raw 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに stale / absent identity contract では fail closed します。 +- **大規模な依存 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 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに stale / absent identity contract では fail closed します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index e605dda2c..4f8f91e56 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -1228,8 +1228,11 @@ private static List FindRankedDependencyCycles( 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(); @@ -1243,8 +1246,11 @@ private static List FindRankedDependencyCycles( { 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); } } @@ -1256,8 +1262,11 @@ private static List FindRankedDependencyCycles( new DependencyCycleEvidenceBreakdown( classifiedReferenceCounts[componentIndex], ToDependencyCycleEvidenceCounts(evidenceByLanguage[componentIndex]), + ToDependencyCycleEvidenceCounts(evidenceByOrigin[componentIndex]), ToDependencyCycleEvidenceCounts(evidenceByResolutionState[componentIndex]), - ToDependencyCycleEvidenceCounts(evidenceByReferenceKind[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) @@ -1373,8 +1382,11 @@ private static JsonObject BuildDependencyCycleEvidenceBreakdownJson(DependencyCy ["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"), }; } @@ -1403,10 +1415,13 @@ internal sealed record DependencyCycleEvidenceCount(string Value, long Reference internal sealed record DependencyCycleEvidenceBreakdown( long ClassifiedReferenceCount, IReadOnlyList BySourceLanguage, + IReadOnlyList ByOrigin, IReadOnlyList ByResolutionState, - IReadOnlyList ByReferenceKind) + IReadOnlyList ByReferenceKind, + IReadOnlyList ByTargetKind, + IReadOnlyList BySuppressionReason) { - internal static readonly DependencyCycleEvidenceBreakdown Empty = new(0, [], [], []); + internal static readonly DependencyCycleEvidenceBreakdown Empty = new(0, [], [], [], [], [], []); } internal sealed record DependencyCycleAnalysis( diff --git a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs index ad88f7447..b4215f5f4 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 @@ -396,8 +399,9 @@ private static JsonObject DependencyProperties() => new() { ["edges"] = Reference("rows"), - ["cycles"] = Reference("rows"), - ["cycle_summaries"] = Reference("rows"), + ["cycles"] = Reference("dependency_cycles"), + ["cycle_summaries"] = Reference("dependency_cycles"), + ["largest_component"] = Nullable(Reference("dependency_cycle")), ["graph"] = new JsonObject { ["type"] = "object", @@ -414,6 +418,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() { @@ -637,11 +656,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/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs index fac13354b..51d2a9cf2 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Mcp; namespace CodeIndex.Tests; @@ -52,6 +53,21 @@ public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_I 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.True( + MatchesSchema(schemaBoundaryPayload, depsSchema, depsSchema), + schemaBoundaryPayload.ToJsonString()); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index 601f43d18..853b011ab 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -139,6 +139,45 @@ public void RunDeps_LargeCycleSeparatesBoundedNodeDisplayFromCompleteAnalysis_Is 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() { diff --git a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json index b1aba5b3a..0dd2f7687 100644 --- a/tests/CodeIndex.Tests/golden/deps-cycles-summary.json +++ b/tests/CodeIndex.Tests/golden/deps-cycles-summary.json @@ -27,6 +27,12 @@ "reference_count": 3 } ], + "by_origin": [ + { + "origin": "symbol_name_match", + "reference_count": 3 + } + ], "by_resolution_state": [ { "resolution_state": "resolved", @@ -38,6 +44,18 @@ "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 + } ] } } @@ -96,6 +114,12 @@ "reference_count": 3 } ], + "by_origin": [ + { + "origin": "symbol_name_match", + "reference_count": 3 + } + ], "by_resolution_state": [ { "resolution_state": "resolved", @@ -107,6 +131,18 @@ "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 + } ] } }, From 82e835db33fd111783beb9dc8a8b8e0bbd0591e8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 04:53:46 +0900 Subject: [PATCH 7/8] Fix dependency cycle graph parity (#5197) --- DEVELOPER_GUIDE.md | 2 ++ TESTING_GUIDE.md | 4 +-- changelog.d/unreleased/5197.changed.md | 4 +-- .../Cli/QueryCommandRunner.Dependencies.cs | 24 ++++++++++++-- .../Mcp/McpToolHandlers.Graph.Dependencies.cs | 19 ++++++++++-- src/CodeIndex/Mcp/McpToolOutputSchemas.cs | 8 +++-- .../McpServerIssue5197Tests.cs | 28 +++++++++++++++++ .../QueryCommandRunnerIssue5197Tests.cs | 31 +++++++++++++++++++ 8 files changed, 110 insertions(+), 10 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6673b426e..7dbb9586f 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2747,6 +2747,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **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 are retained. 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. @@ -6906,6 +6907,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **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 6738245a9..84a02f884 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -309,7 +309,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. @@ -1457,7 +1457,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/changelog.d/unreleased/5197.changed.md b/changelog.d/unreleased/5197.changed.md index 0942e8793..6d6fcb178 100644 --- a/changelog.d/unreleased/5197.changed.md +++ b/changelog.d/unreleased/5197.changed.md @@ -31,8 +31,8 @@ affected: ## 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, 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 and failing closed for stale or absent identity contracts. +- **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 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 展開を利用でき、C# noise 抑制は current な reference-identity metadata だけを使って未解決、曖昧、または解決済みcallの同名非target candidateを除外し、確認済みtargetを保持するとともに stale / absent identity contract では fail closed します。 +- **大規模な依存 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 を保持するとともに stale / absent identity contract では fail closed します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 4f8f91e56..03308b0bd 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -1307,11 +1307,11 @@ private static List FilterEdgesToComponents( .ToList(); } - private sealed record DependencyCycleGraphProjection( + internal sealed record DependencyCycleGraphProjection( List Edges, IReadOnlyList Nodes); - private static DependencyCycleGraphProjection BuildDependencyCycleGraphProjection( + internal static DependencyCycleGraphProjection BuildDependencyCycleGraphProjection( IReadOnlyList edges, IReadOnlyList components, bool includeAllNodes) @@ -1702,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(); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs index 2bb899008..3aa4b2f62 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs @@ -130,7 +130,15 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) sqlGraphSignalPaths, lang); var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; - if (cyclesOnly) + 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, @@ -175,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 b4215f5f4..501bf1d69 100644 --- a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs +++ b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs @@ -408,8 +408,12 @@ private static JsonObject DependencyProperties() ["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(), diff --git a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs index 51d2a9cf2..58773c99e 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5197Tests.cs @@ -44,6 +44,28 @@ public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_I 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)!; @@ -65,6 +87,12 @@ public void ToolsCall_DepsLargeCycleSupportsBoundedSummaryAndExplicitExpansion_I 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()); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index 853b011ab..38ca7b633 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -220,6 +220,37 @@ public void RunDeps_JsonGraphCursorPageReportsReturnedNodeOmissionSeparatelyFrom 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() { From 5be747e8936e0761cfe961b955f663b6f94c0057 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 13:20:30 +0900 Subject: [PATCH 8/8] Filter dependency cycle resolved groups (#5197) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/5197.changed.md | 4 +- .../Database/DbReader.DependencyCyclePlan.cs | 5 + .../QueryCommandRunnerIssue5197Tests.cs | 108 ++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7dbb9586f..12f7ecc2d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2746,7 +2746,7 @@ 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 are retained. Grouping metadata is file-level and explicitly reports that partial-type-family collapse was not applied (#5197). +- **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. diff --git a/changelog.d/unreleased/5197.changed.md b/changelog.d/unreleased/5197.changed.md index 6d6fcb178..0e6226cfa 100644 --- a/changelog.d/unreleased/5197.changed.md +++ b/changelog.d/unreleased/5197.changed.md @@ -31,8 +31,8 @@ affected: ## 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 and failing closed for stale or absent identity contracts. +- **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 を保持するとともに stale / absent identity contract では fail closed します。 +- **大規模な依存 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/Database/DbReader.DependencyCyclePlan.cs b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs index 660990a9e..28884db3c 100644 --- a/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs +++ b/src/CodeIndex/Database/DbReader.DependencyCyclePlan.cs @@ -41,12 +41,17 @@ private DependencyCycleQueryExpressions BuildDependencyCycleQueryExpressions(Dep && _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 " diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs index 38ca7b633..795dc0fba 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5197Tests.cs @@ -292,6 +292,55 @@ public void RunDeps_CSharpNoiseSuppressionRemovesOnlyNonAuthoritativeQualifiedCa 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")] @@ -381,4 +430,63 @@ LIMIT 1 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(); + } }