diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0bc3160936..623ade2668 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1235,7 +1235,7 @@ same source location. | `--exclude-path ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude glob-style path patterns. `*` and `?` are wildcards (repeatable) | | `--exclude-tests` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude likely test files and prefer production code | | `--include-generated` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots` | Include files detected as generated code; generated files are excluded from query results by default | -| `--workspace-db ` | `deps` | Add another CodeIndex database to the file-dependency query. Repeat it to aggregate multiple workspace member DBs; JSON edges include `source_db` and `target_db` so same relative paths can be disambiguated. | +| `--workspace-db ` | `deps` | Add another CodeIndex database to the file-dependency query. Repeat it for up to 7 distinct additional DBs (8 total including `--db`); JSON edges include `source_db` and `target_db` so same relative paths can be disambiguated. | | `--snippet-lines ` | `search`, `references`, `callers`, `callees`, `impact` | Search snippet length or graph `--body` excerpt length (default: 8, max: 20) | | `--snippet-focus ` | `search` | Choose how long search-result lines pick the visible focus when clamped. `quality` (default) prefers full-query matches and strong tokens; `proximity` favors dense multi-token clusters; `leftmost` keeps legacy earliest-match behavior. | | `--max-line-width ` | `search`, `references`, `callers`, `callees`, `find`, `excerpt`, `impact`, `inspect` | Clamp very long single-line snippet/reference/excerpt payloads around the relevant match (`0` disables clamping; default: 512, max: 4096) | @@ -3563,7 +3563,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` | 逆引き: 指定パスに依存しているファイルを表示 | -| `--workspace-db ` | `deps` | file dependency query に別の CodeIndex DB を追加する。複数 member DB を集約する場合は繰り返し指定でき、JSON edge には同じ相対パスを区別できるよう `source_db` / `target_db` が含まれる。 | +| `--workspace-db ` | `deps` | file dependency query に別の CodeIndex DB を追加する。最大 7 個の distinct な追加 DB(`--db` を含め合計 8 個)まで繰り返し指定でき、JSON edge には同じ相対パスを区別できるよう `source_db` / `target_db` が含まれる。 | | `--top ` | クエリ系 | `--limit` のエイリアス | | `--color ` | 全コマンド | ANSI カラー出力の制御。`auto`(既定)、`always`、`never` を受け付ける。優先順位: `--color` フラグ > `CLICOLOR_FORCE` > `NO_COLOR` > `CLICOLOR=0` > 端末能力の自動判定。auto では redirected stdout と StringWriter 風のテスト capture を非 ANSI とみなし、Windows では ConPTY / Windows Terminal の virtual-terminal 対応と `WT_SESSION`、`WT_PROFILE_ID`、`TERM_PROGRAM`、非 `dumb` の `TERM` などの端末ヒントも見る。`cdidx symbols Foo \| less -R` のような pager pipe でも色を維持したい場合は `--color=always`、TTY 上でも ANSI を抑止したい場合は `--color=never`(または `NO_COLOR=1`)を指定する。 | | `--palette ` | 全コマンド | カラー出力が有効なときに用いる ANSI パレットを選択する。`basic`(標準8色 SGR 30–37、最小 SSH/CI 端末向けの既定フォールバック)、`256`(256色 `\x1b[38;5;Nm`)、`truecolor`(24ビット RGB `\x1b[38;2;R;G;Bm`)を受け付ける。優先順位: `--palette` フラグ > `CDIDX_COLOR_PALETTE` 環境変数 > `COLORTERM` / `TERM` 自動判定。`basic` パレットは最小端末で読みにくい `\x1b[90m`(暗灰 / dim)を避ける。 | diff --git a/changelog.d/unreleased/3154.fixed.md b/changelog.d/unreleased/3154.fixed.md new file mode 100644 index 0000000000..58515d2e24 --- /dev/null +++ b/changelog.d/unreleased/3154.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3154 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +--- + +## English + +- **Capped explicit workspace DB dependency fan-out (#3154)** — `deps --workspace-db` now rejects oversized distinct database lists before opening member DBs, limiting cross-database dependency aggregation to 8 total DBs / 56 ordered pairs. + +## 日本語 + +- **明示的なworkspace DB依存集計のfan-outに上限を設けました (#3154)** — `deps --workspace-db` はmember DBを開く前に過大なdistinct DBリストを拒否し、cross-database依存集計を合計8 DB / ordered pair 56件までに制限します。 diff --git a/changelog.d/unreleased/3155.fixed.md b/changelog.d/unreleased/3155.fixed.md new file mode 100644 index 0000000000..0e7ead4744 --- /dev/null +++ b/changelog.d/unreleased/3155.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3155 +affected: + - src/CodeIndex/Database/DbReader.Dependencies.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/DbReaderTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +--- + +## English + +- **Capped dependency symbol samples before result construction (#3155)** — `deps` now keeps full reference counts while limiting the per-edge `symbols` aggregate for single-DB and workspace cross-DB dependency queries. + +## 日本語 + +- **依存関係の symbol サンプルを結果生成前に上限付きにしました (#3155)** — `deps` は参照数を全件のまま保持しつつ、単一DBおよびworkspace cross-DB依存クエリのedgeごとの `symbols` 集計を制限するようになりました。 diff --git a/changelog.d/unreleased/3185.fixed.md b/changelog.d/unreleased/3185.fixed.md new file mode 100644 index 0000000000..785ca36b90 --- /dev/null +++ b/changelog.d/unreleased/3185.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3185 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Detected dependency cycles beyond the display edge limit (#3185)** — CLI and MCP `deps` cycle detection now uses a separate bounded graph candidate budget before applying the requested output limit to cycle graph edges. + +## 日本語 + +- **表示edge上限の外にある依存cycleも検出するようにしました (#3185)** — CLI / MCP の `deps` cycle検出は、要求された出力上限をcycle graph edgeへ適用する前に、別の上限付きgraph候補予算を使うようになりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index ebc6104f85..2e926f1631 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -221,7 +221,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", Commands = Set(DbPathCommands) }, new() { Name = "--read-only", Description = "Open the query database as immutable read-only storage", Commands = Set(ReadOnlyDbCommands) }, new() { Name = "--immutable", Description = "Alias for --read-only", Commands = Set(ReadOnlyDbCommands) }, - new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation", Commands = Set(WorkspaceDbCommands) }, + new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation; repeat up to 7 distinct additional DBs", Commands = Set(WorkspaceDbCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) }, new() { Name = "--json", Description = "JSON output; search/files/validate also accept --json=array for a single JSON array", Commands = Set(JsonCommands) }, new() { Name = "--pretty", Description = "Pretty-print JSON output with indentation", Commands = Set(JsonCommands), TopLevel = true }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 0bbb78028c..167e3dcfa6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -23,6 +23,9 @@ public static class QueryCommandRunner internal const int DefaultMapLimit = 10; internal const int DefaultCompactSectionLimit = 5; internal const int DefaultImpactLimit = 50; + internal const int DefaultDependencyCycleGraphLimit = 1_000; + internal const int MaxWorkspaceDependencyDatabaseCount = 8; + internal const int MaxWorkspaceDependencyDatabasePairCount = MaxWorkspaceDependencyDatabaseCount * (MaxWorkspaceDependencyDatabaseCount - 1); internal const int BatchMaxLineChars = 1024 * 1024; internal const int BatchMaxArgumentCount = 256; internal const int BatchMaxJsonDepth = 32; @@ -4458,42 +4461,62 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteUnexpectedPositionals("deps", options)) return CommandExitCodes.UsageError; + if (TryWriteWorkspaceDependencyFanOutError(options)) + return CommandExitCodes.UsageError; return WithDb(options, jsonOptions, reader => { var reverse = cmdArgs.Any(a => a == "--reverse"); - var results = GetWorkspaceFileDependencies(reader, options, reverse); + var results = GetWorkspaceFileDependencies(reader, options, reverse, options.Limit); + var cycleCandidates = options.DependencyCycles + ? GetWorkspaceFileDependencies(reader, options, reverse, GetDependencyCycleGraphLimit(options.Limit)) + : results; var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); - var sqlGraphSignal = results.Count == 0 - ? baseSqlGraphSignal - : NarrowSqlGraphContractSignalByPaths( - reader, - baseSqlGraphSignal, - results.SelectMany(result => new[] { result.SourcePath, result.TargetPath }), - options.Lang); if (results.Count == 0) { + var zeroSqlGraphSignal = baseSqlGraphSignal; if (options.Json && !reader._hasReferencesTable) - WriteDegradedGraphZeroResult(reader, "edges", json: true, graphAvailable: false, jsonOptions, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, sqlGraphSignal)); + WriteDegradedGraphZeroResult(reader, "edges", json: true, graphAvailable: false, jsonOptions, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, zeroSqlGraphSignal)); else if (options.Json) - Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: "edges", graphTableAvailable: true, degraded: !sqlGraphSignal.Ready, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, sqlGraphSignal)).ToJsonString(jsonOptions)); + Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: "edges", graphTableAvailable: true, degraded: !zeroSqlGraphSignal.Ready, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, zeroSqlGraphSignal)).ToJsonString(jsonOptions)); else { Console.Error.WriteLine(BuildZeroResultLine("No file dependencies found", options)); - WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); + WriteSqlGraphContractWarningIfNeeded(json: false, zeroSqlGraphSignal, reader, options); WriteDegradedGraphZeroResult(reader, "edges", json: false, graphAvailable: reader._hasReferencesTable, jsonOptions); } return ZeroResultExitCode(options); } List> cycles = []; - var outputEdges = options.DependencyCycles ? FilterCycleEdges(results, out cycles) : results; + var outputEdges = options.DependencyCycles + ? FilterCycleEdges(cycleCandidates, out cycles).Take(options.Limit).ToList() + : results; + if (options.DependencyCycles) + cycles = cycles.Take(options.Limit).ToList(); + var sqlGraphSignalPaths = options.DependencyCycles + ? cycles.Count > 0 + ? cycles.SelectMany(static cycle => cycle) + : cycleCandidates.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }) + : results.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }); + var sqlGraphSignal = NarrowSqlGraphContractSignalByPaths( + reader, + baseSqlGraphSignal, + sqlGraphSignalPaths, + options.Lang); if (options.DependencyCycles && cycles.Count == 0) { if (options.Json) - Console.WriteLine(new JsonObject { ["count"] = 0, ["cycles"] = new JsonArray() }.ToJsonString(jsonOptions)); + { + var payload = new JsonObject { ["count"] = 0, ["cycles"] = new JsonArray() }; + AddSqlGraphContractJsonFields(payload, sqlGraphSignal); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } else + { Console.Error.WriteLine(BuildZeroResultLine("No dependency cycles found", options)); + WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); + } return ZeroResultExitCode(options); } @@ -4788,25 +4811,29 @@ private static void WriteDependencyJsonGraph(IReadOnlyList private static string EscapeDot(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); - private static List GetWorkspaceFileDependencies(DbReader primaryReader, QueryCommandOptions options, bool reverse) + internal static int GetDependencyCycleGraphLimit(int displayLimit) + { + var requestedLimit = Math.Max(displayLimit, DefaultDependencyCycleGraphLimit); + return NumericFlagUpperBounds.TryGetValue("--limit", out var maxLimit) + ? Math.Min(requestedLimit, maxLimit) + : requestedLimit; + } + + private static List GetWorkspaceFileDependencies(DbReader primaryReader, QueryCommandOptions options, bool reverse, int limit) { - var results = primaryReader.GetFileDependencies(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, reverse); + var results = primaryReader.GetFileDependencies(limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, reverse); if (options.WorkspaceDbPaths.Count == 0) return results; - var primaryDb = Path.GetFullPath(DbPathResolver.NormalizeDbPath(options.DbPath)); - var memberDbs = options.WorkspaceDbPaths - .Select(path => Path.GetFullPath(DbPathResolver.NormalizeDbPath(path))) - .Prepend(primaryDb) - .Distinct(StringComparer.Ordinal) - .ToList(); + var memberDbs = BuildWorkspaceDependencyDatabaseList(options); + var primaryDb = memberDbs[0]; TagFileDependencyResults(results, primaryDb); foreach (var normalizedDbPath in memberDbs.Skip(1)) { using var db = new DbContext(normalizedDbPath); db.TryMigrateForRead(); var reader = new DbReader(db) { IncludeGenerated = primaryReader.IncludeGenerated }; - var memberResults = reader.GetFileDependencies(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, reverse); + var memberResults = reader.GetFileDependencies(limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, reverse); TagFileDependencyResults(memberResults, normalizedDbPath); results.AddRange(memberResults); } @@ -4816,7 +4843,7 @@ private static List GetWorkspaceFileDependencies(DbReader { if (string.Equals(sourceDb, targetDb, StringComparison.Ordinal)) continue; - results.AddRange(GetCrossDatabaseFileDependencies(sourceDb, targetDb, options, reverse)); + results.AddRange(GetCrossDatabaseFileDependencies(sourceDb, targetDb, options, reverse, limit)); } return results @@ -4825,11 +4852,39 @@ private static List GetWorkspaceFileDependencies(DbReader .ThenBy(result => result.SourcePath, StringComparer.Ordinal) .ThenBy(result => result.TargetDb, StringComparer.Ordinal) .ThenBy(result => result.TargetPath, StringComparer.Ordinal) - .Take(options.Limit) + .Take(limit) + .ToList(); + } + + private static List BuildWorkspaceDependencyDatabaseList(QueryCommandOptions options) + { + var primaryDb = Path.GetFullPath(DbPathResolver.NormalizeDbPath(options.DbPath)); + return options.WorkspaceDbPaths + .Select(path => Path.GetFullPath(DbPathResolver.NormalizeDbPath(path))) + .Prepend(primaryDb) + .Distinct(StringComparer.Ordinal) .ToList(); } - private static List GetCrossDatabaseFileDependencies(string sourceDbPath, string targetDbPath, QueryCommandOptions options, bool reverse) + private static bool TryWriteWorkspaceDependencyFanOutError(QueryCommandOptions options) + { + if (options.WorkspaceDbPaths.Count == 0) + return false; + + var memberDbs = BuildWorkspaceDependencyDatabaseList(options); + var pairCount = memberDbs.Count * (memberDbs.Count - 1); + if (memberDbs.Count <= MaxWorkspaceDependencyDatabaseCount && + pairCount <= MaxWorkspaceDependencyDatabasePairCount) + return false; + + var maxAdditional = MaxWorkspaceDependencyDatabaseCount - 1; + var additionalCount = Math.Max(0, memberDbs.Count - 1); + Console.Error.WriteLine($"Error: deps --workspace-db accepts at most {maxAdditional} distinct additional databases ({MaxWorkspaceDependencyDatabaseCount} total including --db), which is {MaxWorkspaceDependencyDatabasePairCount} ordered cross-database pairs; got {additionalCount} additional ({memberDbs.Count} total, {pairCount} pairs)."); + Console.Error.WriteLine("Hint: pass fewer --workspace-db values or run deps separately for smaller workspace member groups."); + return true; + } + + private static List GetCrossDatabaseFileDependencies(string sourceDbPath, string targetDbPath, QueryCommandOptions options, bool reverse, int limit) { var builder = new SqliteConnectionStringBuilder { @@ -4847,10 +4902,10 @@ private static List GetCrossDatabaseFileDependencies(strin var sourcePathExpr = reverse ? "dst.path" : "src.path"; var targetPathExpr = reverse ? "src.path" : "dst.path"; cmd.CommandText = $@" + WITH edges AS ( SELECT {sourcePathExpr} AS source_path, {targetPathExpr} AS target_path, - COUNT(*) AS reference_count, - GROUP_CONCAT(DISTINCT r.symbol_name) AS symbols + r.symbol_name FROM symbol_references r JOIN files src ON src.id = r.file_id JOIN targetdb.symbols s ON s.name = r.symbol_name @@ -4870,10 +4925,38 @@ FROM symbol_references r ? " AND dst.path NOT LIKE '%test%' COLLATE NOCASE" : " AND src.path NOT LIKE '%test%' COLLATE NOCASE"; cmd.CommandText += @" - GROUP BY source_path, target_path - ORDER BY reference_count DESC, source_path, target_path + ), + edge_totals AS ( + SELECT source_path, + target_path, + COUNT(*) AS reference_count + FROM edges + GROUP BY source_path, target_path + ), + distinct_edge_symbols AS ( + SELECT DISTINCT source_path, target_path, symbol_name + FROM edges + ), + ranked_edge_symbols AS ( + SELECT source_path, + target_path, + symbol_name, + ROW_NUMBER() OVER (PARTITION BY source_path, target_path ORDER BY symbol_name) AS symbol_rank + FROM distinct_edge_symbols + ) + SELECT edge_totals.source_path, + edge_totals.target_path, + edge_totals.reference_count, + COALESCE(GROUP_CONCAT(CASE WHEN ranked_edge_symbols.symbol_rank <= @symbolSampleLimit THEN ranked_edge_symbols.symbol_name END), '') AS symbols + FROM edge_totals + LEFT JOIN ranked_edge_symbols + ON ranked_edge_symbols.source_path = edge_totals.source_path + AND ranked_edge_symbols.target_path = edge_totals.target_path + GROUP BY edge_totals.source_path, edge_totals.target_path, edge_totals.reference_count + ORDER BY edge_totals.reference_count DESC, edge_totals.source_path, edge_totals.target_path LIMIT @limit"; - cmd.Parameters.AddWithValue("@limit", options.Limit); + cmd.Parameters.AddWithValue("@limit", limit); + cmd.Parameters.AddWithValue("@symbolSampleLimit", DbReader.DependencySymbolSampleLimit); var results = new List(); using var reader = cmd.ExecuteReader(); @@ -9407,7 +9490,7 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont private static readonly Dictionary MissingOptionValueHints = new(StringComparer.Ordinal) { ["--db"] = "pass a path to a CodeIndex SQLite database, e.g. `--db .cdidx/codeindex.db` or `--db file:///absolute/path/to/codeindex.db?immutable=1`, or omit `--db` to use `.cdidx/codeindex.db`.", - ["--workspace-db"] = "pass a path to another workspace member CodeIndex SQLite database. Repeat the flag to aggregate multiple member DBs.", + ["--workspace-db"] = "pass a path to another workspace member CodeIndex SQLite database. Repeat the flag up to 7 distinct additional DBs to aggregate multiple member DBs.", ["--data-dir"] = "pass a directory where cdidx should store `codeindex.db`, e.g. `--data-dir /var/cache/cdidx`.", ["--limit"] = "pass a positive integer, e.g. `--limit 20` (default 20).", ["--top"] = "pass a positive integer, e.g. `--top 20` (alias for `--limit`, default 20).", diff --git a/src/CodeIndex/Database/DbReader.Dependencies.cs b/src/CodeIndex/Database/DbReader.Dependencies.cs index d4ed6ba668..ab7f2e6b79 100644 --- a/src/CodeIndex/Database/DbReader.Dependencies.cs +++ b/src/CodeIndex/Database/DbReader.Dependencies.cs @@ -7,6 +7,8 @@ namespace CodeIndex.Database; public partial class DbReader { + internal const int DependencySymbolSampleLimit = 32; + private string ReferenceContextSql(string referenceAlias, string referenceLineAlias = "rl") => _canUseReferenceLines ? $"COALESCE({referenceAlias}.context, {referenceLineAlias}.context)" @@ -491,14 +493,35 @@ AND mrs.source_file_id IS NOT NULL -- (multiple same-name attribute / annotation classes) are dropped. -- metadata エッジは同名 class 系 target が 1 つだけのときのみ残す。 AND (snc.is_metadata = 0 OR COALESCE(ta.class_like_target_count, 0) <= 1) + ), + edge_totals AS ( + SELECT source_path, + target_path, + SUM(ref_count) AS reference_count + FROM edges + GROUP BY source_path, target_path + ), + distinct_edge_symbols AS ( + SELECT DISTINCT source_path, target_path, symbol_name + FROM edges + ), + ranked_edge_symbols AS ( + SELECT source_path, + target_path, + symbol_name, + ROW_NUMBER() OVER (PARTITION BY source_path, target_path ORDER BY symbol_name) AS symbol_rank + FROM distinct_edge_symbols ) - SELECT source_path, - target_path, - SUM(ref_count) AS reference_count, - GROUP_CONCAT(symbol_name) AS symbols - FROM edges - GROUP BY source_path, target_path - ORDER BY reference_count DESC, source_path, target_path + SELECT edge_totals.source_path, + edge_totals.target_path, + edge_totals.reference_count, + COALESCE(GROUP_CONCAT(CASE WHEN ranked_edge_symbols.symbol_rank <= @symbolSampleLimit THEN ranked_edge_symbols.symbol_name END), '') AS symbols + FROM edge_totals + LEFT JOIN ranked_edge_symbols + ON ranked_edge_symbols.source_path = edge_totals.source_path + AND ranked_edge_symbols.target_path = edge_totals.target_path + GROUP BY edge_totals.source_path, edge_totals.target_path, edge_totals.reference_count + ORDER BY edge_totals.reference_count DESC, edge_totals.source_path, edge_totals.target_path LIMIT @limit"; cmd.CommandText = sql; @@ -515,6 +538,7 @@ FROM edges cmd.Parameters.AddWithValue($"@excludePath{i}", BuildPathLikePattern(excludePathPatterns[i])); } cmd.Parameters.AddWithValue("@limit", limit); + cmd.Parameters.AddWithValue("@symbolSampleLimit", DependencySymbolSampleLimit); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index b2a6cbc88a..8750eb0da1 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3192,16 +3192,26 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { var results = reader.GetFileDependencies(limit, lang, pathPatterns, excludePaths, excludeTests, reverse); + var cycleCandidates = cyclesOnly + ? reader.GetFileDependencies(QueryCommandRunner.GetDependencyCycleGraphLimit(limit), lang, pathPatterns, excludePaths, excludeTests, reverse) + : results; var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); + List> cycles = []; + var outputEdges = cyclesOnly ? QueryCommandRunner.FilterCycleEdges(cycleCandidates, out cycles).Take(limit).ToList() : results; + if (cyclesOnly) + cycles = cycles.Take(limit).ToList(); + var sqlGraphSignalPaths = cyclesOnly + ? cycles.Count > 0 + ? cycles.SelectMany(static cycle => cycle) + : cycleCandidates.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }) + : results.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }); var sqlGraphSignal = results.Count == 0 ? baseSqlGraphSignal : QueryCommandRunner.NarrowSqlGraphContractSignalByPaths( reader, baseSqlGraphSignal, - results.SelectMany(result => new[] { result.SourcePath, result.TargetPath }), + sqlGraphSignalPaths, lang); - List> cycles = []; - var outputEdges = cyclesOnly ? QueryCommandRunner.FilterCycleEdges(results, out cycles) : results; var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; if (cyclesOnly) payload["cycles"] = QueryCommandRunner.BuildDependencyCyclesJson(cycles); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 84fd0364c6..d91a5cc2d5 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -5713,6 +5713,60 @@ FROM dbo.Orders o Assert.Equal(1, dependency.ReferenceCount); } + [Fact] + public void GetFileDependencies_CapsDenseSymbolSample_Issue3155() + { + var sourceFileId = InsertSyntheticDependencyFile("src/DenseCaller.cs"); + var targetFileId = InsertSyntheticDependencyFile("src/DenseTarget.cs"); + var symbolNames = Enumerable + .Range(0, DbReader.DependencySymbolSampleLimit + 5) + .Select(index => $"DenseTarget{index:D2}") + .ToArray(); + + _writer.InsertSymbols(symbolNames.Select((name, index) => new SymbolRecord + { + FileId = targetFileId, + Kind = "class", + Name = name, + Line = index + 1, + StartLine = index + 1, + EndLine = index + 1, + }).ToArray()); + _writer.InsertReferences(symbolNames.Select((name, index) => new ReferenceRecord + { + FileId = sourceFileId, + SymbolName = name, + ReferenceKind = "type_reference", + Line = index + 1, + Column = 1, + Context = name, + }).ToArray()); + + var dependency = Assert.Single(_reader.GetFileDependencies( + limit: 10, + lang: "csharp", + pathPatterns: ["DenseCaller.cs"], + excludePathPatterns: null, + excludeTests: false)); + + Assert.Equal(symbolNames.Length, dependency.ReferenceCount); + Assert.Equal(DbReader.DependencySymbolSampleLimit, dependency.Symbols.Split(',').Length); + Assert.DoesNotContain(symbolNames[^1], dependency.Symbols); + } + + private long InsertSyntheticDependencyFile(string path) + { + return _writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = 1, + Lines = 1, + Modified = new DateTime(2026, 6, 6, 0, 0, 0, DateTimeKind.Utc), + Checksum = Guid.NewGuid().ToString("N"), + }); + } + [Fact] public void SqlQualifiedNames_SameLineCrossSchemaCallStillReachesReaders() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index fa71fa6744..6ba8999fdd 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6690,6 +6690,77 @@ public void ToolsCall_Deps_ZeroResultSqlScopeStillIncludesDegradedState() } } + [Fact] + public void ToolsCall_DepsCyclesUsesGraphBudgetBeyondDisplayLimit_Issue3185() + { + var writer = new DbWriter(_db.Connection); + var highTargetId = InsertDependencyFile(writer, "src/HighTarget.cs"); + var highCallerId = InsertDependencyFile(writer, "src/HighCaller.cs"); + var cycleAId = InsertDependencyFile(writer, "src/CycleA.cs"); + var cycleBId = InsertDependencyFile(writer, "src/CycleB.cs"); + var cycleCId = InsertDependencyFile(writer, "src/CycleC.cs"); + var cycleDId = InsertDependencyFile(writer, "src/CycleD.cs"); + InsertDependencySymbols(writer, highTargetId, ["HighTarget"]); + InsertDependencyReferences(writer, highCallerId, Enumerable.Repeat("HighTarget", 5).ToArray()); + InsertDependencySymbols(writer, cycleAId, ["CycleA"]); + InsertDependencyReferences(writer, cycleAId, ["CycleB"]); + InsertDependencySymbols(writer, cycleBId, ["CycleB"]); + InsertDependencyReferences(writer, cycleBId, ["CycleA"]); + InsertDependencySymbols(writer, cycleCId, ["CycleC"]); + InsertDependencyReferences(writer, cycleCId, ["CycleD"]); + InsertDependencySymbols(writer, cycleDId, ["CycleD"]); + InsertDependencyReferences(writer, cycleDId, ["CycleC"]); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deps","arguments":{"cycles":true,"limit":1,"lang":"csharp"}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var cycle = Assert.Single(structured["cycles"]!.AsArray()); + var nodes = cycle!["nodes"]!.AsArray().Select(node => node!.GetValue()).ToArray(); + + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.Equal(2, nodes.Length); + Assert.All(nodes, node => Assert.StartsWith("src/Cycle", node)); + } + + private static long InsertDependencyFile(DbWriter writer, string path) + { + return writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = 1, + Lines = 1, + Modified = new DateTime(2026, 6, 6, 0, 0, 0, DateTimeKind.Utc), + Checksum = Guid.NewGuid().ToString("N"), + }); + } + + private static void InsertDependencySymbols(DbWriter writer, long fileId, IReadOnlyList symbolNames) + { + writer.InsertSymbols(symbolNames.Select((symbolName, index) => new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = symbolName, + Line = index + 1, + StartLine = index + 1, + EndLine = index + 1, + }).ToArray()); + } + + private static void InsertDependencyReferences(DbWriter writer, long fileId, IReadOnlyList symbolNames) + { + writer.InsertReferences(symbolNames.Select((symbolName, index) => new ReferenceRecord + { + FileId = fileId, + SymbolName = symbolName, + ReferenceKind = "type_reference", + Line = index + 1, + Column = 1, + Context = symbolName, + }).ToArray()); + } + [Fact] public void ToolsCall_Deps_JsonGraph_ReturnsGraphPayload() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index f3bd743fea..0d91d9dda5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -3592,6 +3592,41 @@ public void FindDependencyCycles_ReturnsStronglyConnectedFileComponents() Assert.Equal(["a.cs", "b.cs"], cycle); } + [Fact] + public void RunDeps_CyclesUsesGraphBudgetBeyondDisplayLimit_Issue3185() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_deps_cycle_budget"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + InsertFileWithSymbol(dbPath, "src/HighTarget.cs", "HighTarget"); + InsertFileWithReferences(dbPath, "src/HighCaller.cs", Enumerable.Repeat("HighTarget", 5).ToArray()); + InsertFileWithSymbolsAndReferences(dbPath, "src/CycleA.cs", ["CycleA"], ["CycleB"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/CycleB.cs", ["CycleB"], ["CycleA"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/CycleC.cs", ["CycleC"], ["CycleD"]); + InsertFileWithSymbolsAndReferences(dbPath, "src/CycleD.cs", ["CycleD"], ["CycleC"]); + MarkDependencyGraphReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--cycles", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var cycle = Assert.Single(document.RootElement.GetProperty("cycles").EnumerateArray()); + var nodes = cycle.GetProperty("nodes").EnumerateArray().Select(node => node.GetString()).ToArray(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(1, document.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(2, nodes.Length); + Assert.All(nodes, node => Assert.StartsWith("src/Cycle", node)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunDeps_ZeroJson_StaleSqlGraphContractIncludesDegradedStateWhenSqlScopeIsEmpty() { @@ -3716,7 +3751,80 @@ public void RunDeps_WorkspaceDbJson_AggregatesAndTagsMemberDatabaseEdges() } } + [Fact] + public void RunDeps_WorkspaceDbJson_CapsCrossDatabaseSymbolSample_Issue3155() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_symbols_primary"); + var memberRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_symbols_member"); + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var memberDb = TestProjectHelper.CreateProjectDb(memberRoot); + var symbolNames = Enumerable + .Range(0, DbReader.DependencySymbolSampleLimit + 5) + .Select(index => $"SharedTarget{index:D2}") + .ToArray(); + InsertFileWithReferences(primaryDb, "src/PrimaryCaller.cs", symbolNames); + InsertFileWithSymbols(memberDb, "src/SharedTargets.cs", symbolNames); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", primaryDb, "--workspace-db", memberDb, "--json", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var edge = Assert.Single(document.RootElement.GetProperty("edges").EnumerateArray()); + var sampledSymbols = edge.GetProperty("symbols").GetString()!.Split(','); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.NotNull(stderr); + Assert.Equal(symbolNames.Length, edge.GetProperty("reference_count").GetInt32()); + Assert.Equal(DbReader.DependencySymbolSampleLimit, sampledSymbols.Length); + Assert.DoesNotContain(symbolNames[^1], sampledSymbols); + } + finally + { + TestProjectHelper.DeleteDirectory(primaryRoot); + TestProjectHelper.DeleteDirectory(memberRoot); + } + } + + [Fact] + public void RunDeps_WorkspaceDbTooManyDistinctDatabases_ReturnsUsageError_Issue3154() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_fanout_primary"); + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var args = new List { "--db", primaryDb, "--json" }; + for (var i = 0; i < QueryCommandRunner.MaxWorkspaceDependencyDatabaseCount; i++) + args.AddRange(["--workspace-db", Path.Combine(Path.GetTempPath(), $"cdidx_member_{Guid.NewGuid():N}.db")]); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + args.ToArray(), + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("deps --workspace-db accepts at most", stderr); + Assert.Contains("ordered cross-database pairs", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(primaryRoot); + } + } + private static void InsertFileWithSymbol(string dbPath, string path, string symbolName) + => InsertFileWithSymbols(dbPath, path, [symbolName]); + + private static void InsertFileWithSymbols(string dbPath, string path, IReadOnlyList symbolNames) + => InsertFileWithSymbolsAndReferences(dbPath, path, symbolNames, []); + + private static void InsertFileWithSymbolsAndReferences( + string dbPath, + string path, + IReadOnlyList symbolNames, + IReadOnlyList referenceNames) { using var db = new DbContext(dbPath); var writer = new DbWriter(db.Connection); @@ -3729,43 +3837,40 @@ private static void InsertFileWithSymbol(string dbPath, string path, string symb Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), Checksum = Guid.NewGuid().ToString("N"), }); - writer.InsertSymbols([ + writer.InsertSymbols(symbolNames.Select((symbolName, index) => new SymbolRecord { FileId = fileId, Kind = "class", Name = symbolName, - Line = 1, - StartLine = 1, - EndLine = 1, - } - ]); - } - - private static void InsertFileWithReference(string dbPath, string path, string symbolName) - { - using var db = new DbContext(dbPath); - var writer = new DbWriter(db.Connection); - var fileId = writer.UpsertFile(new FileRecord - { - Path = path, - Lang = "csharp", - Size = 1, - Lines = 1, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), - Checksum = Guid.NewGuid().ToString("N"), - }); - writer.InsertReferences([ + Line = index + 1, + StartLine = index + 1, + EndLine = index + 1, + }).ToArray()); + writer.InsertReferences(referenceNames.Select((symbolName, index) => new ReferenceRecord { FileId = fileId, SymbolName = symbolName, ReferenceKind = "type_reference", - Line = 1, + Line = index + 1, Column = 1, Context = symbolName, - } - ]); + }).ToArray()); + } + + private static void InsertFileWithReference(string dbPath, string path, string symbolName) + => InsertFileWithReferences(dbPath, path, [symbolName]); + + private static void InsertFileWithReferences(string dbPath, string path, IReadOnlyList symbolNames) + => InsertFileWithSymbolsAndReferences(dbPath, path, [], symbolNames); + + private static void MarkDependencyGraphReady(string dbPath) + { + using var db = new DbContext(dbPath); + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + writer.MarkCSharpSymbolNameContractReady(); }