From 9fd9be9ecc74b6021a249845745de80f735f481d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 23:44:13 +0900 Subject: [PATCH] Fix cross-db deps workspace aggregation (#1704) --- USER_GUIDE.md | 2 + changelog.d/unreleased/1704.added.md | 18 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 3 + src/CodeIndex/Cli/QueryCommandRunner.cs | 169 +++++++++++++++++- src/CodeIndex/Models/QueryResults.cs | 4 + .../QueryCommandRunnerTests.cs | 88 +++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1704.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2db041fef7..0ec0c1eabd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -932,6 +932,7 @@ cdidx report --output report.tgz --json | `--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. | | `--snippet-lines ` | `search` | Search snippet length for human-readable output and JSON/MCP snippets (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`, `find`, `excerpt`, `inspect` | Clamp very long single-line snippet/reference/excerpt payloads around the relevant match (`0` disables clamping; default: 512, max: 4096) | @@ -2807,6 +2808,7 @@ cdidx report --output report.tgz --json | `--since ` | `search`, `definition`, `symbols`, `files` | 指定タイムスタンプ以降に変更されたファイルのみ(ISO 8601)。オフセットなしの値(例: `2024-01-01T00:00:00`)は UTC として解釈されるため、どのタイムゾーンから呼び出しても同じ UTC 時点になります。明示したい場合は末尾に `Z` または `+09:00` 等のオフセットを付与してください。 | | `--no-dedup` | `search` | オーバーラップチャンク重複排除を無効化 | | `--reverse` | `deps` | 逆引き: 指定パスに依存しているファイルを表示 | +| `--workspace-db ` | `deps` | file dependency query に別の CodeIndex DB を追加する。複数 member DB を集約する場合は繰り返し指定でき、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/1704.added.md b/changelog.d/unreleased/1704.added.md new file mode 100644 index 0000000000..69b59c1820 --- /dev/null +++ b/changelog.d/unreleased/1704.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1704 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Models/QueryResults.cs + - USER_GUIDE.md +--- + +## English + +- **`deps` can aggregate explicit workspace member databases (#1704)** — `deps --workspace-db ` can be repeated to include dependency edges from additional CodeIndex databases, and JSON edges now include `source_db` / `target_db` tags when workspace DB aggregation is active. + +## 日本語 + +- **`deps` が明示指定した workspace member DB を集約できるようになりました (#1704)** — `deps --workspace-db ` を繰り返し指定して追加の CodeIndex DB から dependency edge を含められるようになり、workspace DB 集約時の JSON edge には `source_db` / `target_db` タグが含まれます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index db167b5220..24eafded0f 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -148,6 +148,8 @@ internal static class CliFlagSchema "validate", "deps", "impact", "unused", "hotspots", "db", "vacuum", "report", "batch", "mcp", ]; + private static readonly string[] WorkspaceDbCommands = ["deps"]; + private static readonly string[] DataDirCommands = [ "index", "search", "definition", "references", "callers", "callees", @@ -178,6 +180,7 @@ private static IReadOnlyList BuildAll() return new List { new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", Commands = Set(DbPathCommands) }, + new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation", 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 also accepts --json=array for a single JSON array", Commands = Set(JsonCommands) }, new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 9710ca3b14..717edb7968 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2627,7 +2627,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) return WithDb(options, jsonOptions, reader => { var reverse = cmdArgs.Any(a => a == "--reverse"); - var results = reader.GetFileDependencies(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, reverse); + var results = GetWorkspaceFileDependencies(reader, options, reverse); var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var sqlGraphSignal = results.Count == 0 ? baseSqlGraphSignal @@ -2675,6 +2675,163 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } + private static List GetWorkspaceFileDependencies(DbReader primaryReader, QueryCommandOptions options, bool reverse) + { + var results = primaryReader.GetFileDependencies(options.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(); + 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); + TagFileDependencyResults(memberResults, normalizedDbPath); + results.AddRange(memberResults); + } + + foreach (var sourceDb in memberDbs) + foreach (var targetDb in memberDbs) + { + if (string.Equals(sourceDb, targetDb, StringComparison.Ordinal)) + continue; + results.AddRange(GetCrossDatabaseFileDependencies(sourceDb, targetDb, options, reverse)); + } + + return results + .OrderByDescending(result => result.ReferenceCount) + .ThenBy(result => result.SourceDb, StringComparer.Ordinal) + .ThenBy(result => result.SourcePath, StringComparer.Ordinal) + .ThenBy(result => result.TargetDb, StringComparer.Ordinal) + .ThenBy(result => result.TargetPath, StringComparer.Ordinal) + .Take(options.Limit) + .ToList(); + } + + private static List GetCrossDatabaseFileDependencies(string sourceDbPath, string targetDbPath, QueryCommandOptions options, bool reverse) + { + var builder = new SqliteConnectionStringBuilder + { + DataSource = sourceDbPath, + Mode = SqliteOpenMode.ReadOnly, + }; + using var connection = new SqliteConnection(builder.ConnectionString); + connection.Open(); + using var attach = connection.CreateCommand(); + attach.CommandText = "ATTACH DATABASE @targetDb AS targetdb"; + attach.Parameters.AddWithValue("@targetDb", targetDbPath); + attach.ExecuteNonQuery(); + + using var cmd = connection.CreateCommand(); + var sourcePathExpr = reverse ? "dst.path" : "src.path"; + var targetPathExpr = reverse ? "src.path" : "dst.path"; + cmd.CommandText = $@" + SELECT {sourcePathExpr} AS source_path, + {targetPathExpr} AS target_path, + COUNT(*) AS reference_count, + GROUP_CONCAT(DISTINCT r.symbol_name) AS symbols + FROM symbol_references r + JOIN files src ON src.id = r.file_id + JOIN targetdb.symbols s ON s.name = r.symbol_name + JOIN targetdb.files dst ON dst.id = s.file_id + WHERE 1 = 1"; + if (options.Lang != null) + { + cmd.CommandText += " AND src.lang = @lang AND dst.lang = @lang"; + cmd.Parameters.AddWithValue("@lang", options.Lang); + } + AddCrossDatabasePathFilters(cmd, "src", options.PathPatterns, include: !reverse); + AddCrossDatabasePathFilters(cmd, "dst", options.PathPatterns, include: reverse); + AddCrossDatabaseExcludeFilters(cmd, "src", options.ExcludePaths, include: !reverse); + AddCrossDatabaseExcludeFilters(cmd, "dst", options.ExcludePaths, include: reverse); + if (options.ExcludeTests) + cmd.CommandText += reverse + ? " 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 + LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", options.Limit); + + var results = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + results.Add(new FileDependencyResult + { + SourcePath = reader.GetString(0), + TargetPath = reader.GetString(1), + SourceDb = reverse ? targetDbPath : sourceDbPath, + TargetDb = reverse ? sourceDbPath : targetDbPath, + ReferenceCount = reader.GetInt32(2), + Symbols = reader.IsDBNull(3) ? string.Empty : reader.GetString(3), + }); + } + return results; + } + + private static void AddCrossDatabasePathFilters(SqliteCommand cmd, string alias, IReadOnlyList patterns, bool include) + { + if (!include || patterns.Count == 0) + return; + var parts = new List(patterns.Count); + for (var i = 0; i < patterns.Count; i++) + { + var name = $"@crossPath{alias}{i}"; + parts.Add($"{alias}.path LIKE {name} ESCAPE '\\'"); + cmd.Parameters.AddWithValue(name, CrossDatabaseGlobToLikePattern(patterns[i])); + } + cmd.CommandText += " AND (" + string.Join(" OR ", parts) + ")"; + } + + private static void AddCrossDatabaseExcludeFilters(SqliteCommand cmd, string alias, IReadOnlyList patterns, bool include) + { + if (!include || patterns.Count == 0) + return; + for (var i = 0; i < patterns.Count; i++) + { + var name = $"@crossExclude{alias}{i}"; + cmd.CommandText += $" AND {alias}.path NOT LIKE {name} ESCAPE '\\'"; + cmd.Parameters.AddWithValue(name, CrossDatabaseGlobToLikePattern(patterns[i])); + } + } + + private static string CrossDatabaseGlobToLikePattern(string pattern) + { + var builder = new System.Text.StringBuilder(pattern.Length); + foreach (var ch in pattern) + { + builder.Append(ch switch + { + '*' => '%', + '?' => '_', + '%' => "\\%", + '_' => "\\_", + '\\' => "\\\\", + _ => ch, + }); + } + return builder.ToString(); + } + + private static void TagFileDependencyResults(IEnumerable results, string dbPath) + { + foreach (var result in results) + { + result.SourceDb = dbPath; + result.TargetDb = dbPath; + } + } + public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOptions) { bool groupByName = cmdArgs.Any(a => a == "--group-by-name"); @@ -3513,6 +3670,7 @@ public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, boo bool contextAfterExplicit = false; var pathPatterns = new List(); var userPathPatterns = new List(); + var workspaceDbPaths = new List(); var projectFilters = new List(); string? solutionFilter = null; var excludePaths = new List(); @@ -3640,6 +3798,12 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(dbPathError!); break; + case "--workspace-db": + if (TryReadStringOptionValue(args, ref i, "--workspace-db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var workspaceDbPath, out var workspaceDbError)) + workspaceDbPaths.Add(workspaceDbPath!); + else + AddParseError(workspaceDbError!); + break; case "--data-dir": if (TryReadStringOptionValue(args, ref i, "--data-dir", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dataDirValue, out var dataDirError)) { @@ -4125,6 +4289,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) SnippetFocus = snippetFocus, MaxLineWidth = maxLineWidth, PathPatterns = pathPatterns, + WorkspaceDbPaths = workspaceDbPaths, ProjectFilters = projectFilters, SolutionFilter = solutionFilter, ExcludePaths = excludePaths, @@ -6235,6 +6400,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.", ["--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).", @@ -6570,6 +6736,7 @@ public sealed class QueryCommandOptions public SearchSnippetFocusMode SnippetFocus { get; init; } = SearchSnippetFocusMode.Quality; public int MaxLineWidth { get; init; } = LineWidthFormatter.DefaultMaxLineWidth; public List PathPatterns { get; init; } = []; + public List WorkspaceDbPaths { get; init; } = []; public List ProjectFilters { get; init; } = []; public string? SolutionFilter { get; init; } public List ExcludePaths { get; init; } = []; diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 6902f31ad0..8689bee843 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -843,6 +843,10 @@ public class FileDependencyResult { public string SourcePath { get; set; } = string.Empty; public string TargetPath { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SourceDb { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetDb { get; set; } public int ReferenceCount { get; set; } public string Symbols { get; set; } = string.Empty; } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 57a1909e9f..66adfa9847 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -12322,6 +12322,94 @@ public void RunDeps_Json_StaleSqlGraphContractIncludesDegradedState() } } + [Fact] + public void RunDeps_WorkspaceDbJson_AggregatesAndTagsMemberDatabaseEdges() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_primary"); + var memberRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_member"); + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var memberDb = TestProjectHelper.CreateProjectDb(memberRoot); + InsertFileWithReference(primaryDb, "src/PrimaryCaller.cs", "SharedTarget"); + InsertFileWithSymbol(memberDb, "src/SharedTarget.cs", "SharedTarget"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", primaryDb, "--workspace-db", memberDb, "--json", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var edges = json.GetProperty("edges").EnumerateArray().ToArray(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.NotNull(stderr); + Assert.Equal(1, json.GetProperty("count").GetInt32()); + var edge = Assert.Single(edges); + Assert.Equal("src/PrimaryCaller.cs", edge.GetProperty("source_path").GetString()); + Assert.Equal("src/SharedTarget.cs", edge.GetProperty("target_path").GetString()); + Assert.Equal(Path.GetFullPath(primaryDb), edge.GetProperty("source_db").GetString()); + Assert.Equal(Path.GetFullPath(memberDb), edge.GetProperty("target_db").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(primaryRoot); + TestProjectHelper.DeleteDirectory(memberRoot); + } + } + + private static void InsertFileWithSymbol(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.InsertSymbols([ + 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([ + new ReferenceRecord + { + FileId = fileId, + SymbolName = symbolName, + ReferenceKind = "type_reference", + Line = 1, + Column = 1, + Context = symbolName, + } + ]); + } + [Fact] public void RunImpact_CountOnlyJson_StaleSqlGraphContractIncludesDegradedState() {