From dc87e7d5b7a920f6f5c394d606caa8a3f14e4d73 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 20:43:29 +0900 Subject: [PATCH 1/2] Add map and deps controls for issues #1634 #1635 --- changelog.d/unreleased/1634.added.md | 17 + changelog.d/unreleased/1635.added.md | 17 + src/CodeIndex/Cli/CliFlagSchema.cs | 8 +- src/CodeIndex/Cli/ConsoleUi.cs | 4 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 301 +++++++++++++++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 12 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 82 ++++- .../QueryCommandRunnerTests.cs | 47 +++ 8 files changed, 461 insertions(+), 27 deletions(-) create mode 100644 changelog.d/unreleased/1634.added.md create mode 100644 changelog.d/unreleased/1635.added.md diff --git a/changelog.d/unreleased/1634.added.md b/changelog.d/unreleased/1634.added.md new file mode 100644 index 0000000000..d2f03d2f4b --- /dev/null +++ b/changelog.d/unreleased/1634.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1634 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs +--- + +## English + +- **`deps` can export graph-shaped output and dependency cycles (#1634)** — `deps` now accepts graph export formats and a cycle-only mode so callers can feed dependency data directly to visualization or cycle-analysis tooling. + +## 日本語 + +- **`deps` がグラフ形式の出力と依存サイクル検出に対応しました (#1634)** — `deps` はグラフ出力形式とサイクルのみを返すモードを受け付けるようになり、可視化やサイクル分析ツールへ依存データを直接渡せます。 diff --git a/changelog.d/unreleased/1635.added.md b/changelog.d/unreleased/1635.added.md new file mode 100644 index 0000000000..bdc02b6274 --- /dev/null +++ b/changelog.d/unreleased/1635.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1635 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs +--- + +## English + +- **`map` can return selected sections with bounded module depth (#1635)** — `map` now accepts section selection and depth control so MCP and CLI callers can request smaller repo overviews when they do not need the full payload. + +## 日本語 + +- **`map` がセクション選択とモジュール深さ制御に対応しました (#1635)** — `map` は必要なセクションと深さを指定できるようになり、MCP / CLI 呼び出し側が全体ペイロード不要時に小さなリポジトリ俯瞰を取得できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index fc61347e7f..55a68752c9 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -119,6 +119,8 @@ internal static class CliFlagSchema private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files"]; private static readonly string[] ByteFormatCommands = ["files", "map"]; private static readonly string[] EntrypointConfidenceCommands = ["map"]; + private static readonly string[] MapSectionCommands = ["map"]; + private static readonly string[] DependencyCycleCommands = ["deps"]; // `--exact` is the legacy shorthand that every name-resolution command accepts. // `--exact` は名前解決系の全コマンドで受け付けるレガシー shorthand。 @@ -180,7 +182,7 @@ internal static class CliFlagSchema private static readonly string[] FormatCommands = [ - "search", "definition", "references", "callers", "callees", "find", "validate", + "search", "definition", "references", "callers", "callees", "find", "validate", "deps", ]; private static readonly string[] ProfileCommands = @@ -234,6 +236,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--since", ValuePlaceholder = "", Description = "Filter by modified-since timestamp", Commands = Set(SinceCommands) }, new() { Name = "--bytes", Description = "Show raw byte counts in human output", Commands = Set(ByteFormatCommands) }, new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", Commands = Set(EntrypointConfidenceCommands) }, + new() { Name = "--sections", ValuePlaceholder = "", Description = "Map: comma-separated response sections to include", Commands = Set(MapSectionCommands) }, + new() { Name = "--cycles", Description = "Deps: return dependency cycles instead of edge rows", Commands = Set(DependencyCycleCommands) }, new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", Commands = Set(QueryCommands) }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, @@ -256,7 +260,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--focus-column", ValuePlaceholder = "", Description = "Focused column to keep visible when clamping", Commands = Set("find", "excerpt") }, new() { Name = "--focus-length", ValuePlaceholder = "", Description = "Focused span width when clamping", Commands = Set("excerpt") }, new() { Name = "--max-hops", ValuePlaceholder = "", Description = "Impact: max BFS hops", Commands = Set("impact") }, - new() { Name = "--depth", ValuePlaceholder = "", Description = "Impact: deprecated alias for --max-hops", Commands = Set("impact") }, + new() { Name = "--depth", ValuePlaceholder = "", Description = "Map: cap module depth; impact: deprecated alias for --max-hops", Commands = Set("impact", "map") }, new() { Name = "--with-paths", Description = "Impact: include shortest call chains per caller", Commands = Set("impact") }, new() { Name = "--reverse", Description = "Reverse direction (show dependents)", Commands = Set("deps") }, new() { Name = "--group-by", ValuePlaceholder = "", Description = "Hotspots: choose grouping unit", Commands = Set("hotspots") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b739596c8c..d522051b0d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -86,7 +86,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("files", "cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), - ("map", "cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--min-entrypoint-confidence <0.0..1.0>]"), + ("map", "cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--verbose]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), @@ -98,7 +98,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db ] [--json] [--format ] [--verbose] [--kind ] [--path ]"), ("impact", "cdidx impact |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--count] [--with-paths]"), - ("deps", "cdidx deps [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse]"), + ("deps", "cdidx deps [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles]"), ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b939afffa8..12ecf36cba 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -94,6 +94,7 @@ public static class QueryCommandRunner "--slow-query-ms", "--format", "--min-entrypoint-confidence", + "--sections", ]; private sealed record StatusReadinessField( string FieldName, @@ -190,6 +191,7 @@ private sealed record StatusReadinessField( "--silent", "--by-bucket", "--all", + "--cycles", "--group-by-name", "--with-paths", "--bytes", @@ -207,6 +209,10 @@ private sealed record StatusReadinessField( private const string OutputFormatCompact = "compact"; private const string OutputFormatCsv = "csv"; private const string OutputFormatTsv = "tsv"; + private const string OutputFormatDot = "dot"; + private const string OutputFormatGraphMl = "graphml"; + private const string OutputFormatJsonGraph = "json-graph"; + private const string OutputFormatEdgeList = "edgelist"; private static readonly HashSet InlineValueOptions = new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; @@ -2359,6 +2365,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var map = reader.GetRepoMap(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.MinEntrypointConfidence); WorkspaceMetadataEnricher.Enrich(map, options.DbPath, options.DbPathExplicit); + if (options.ContextAfterExplicit) + ApplyRepoMapDepth(map, options.ContextAfter); // Return not-found only when a narrowing filter is active and produces zero files. // Unfiltered empty indexes return success (valid state for health probes). @@ -2380,7 +2388,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { - Console.WriteLine(JsonSerializer.Serialize(map, CliJsonSerializerContextFactory.Create(jsonOptions).RepoMapResult)); + var payload = BuildRepoMapJsonPayload(map, options, jsonOptions); + Console.WriteLine(payload.ToJsonString(jsonOptions)); } else { @@ -2402,23 +2411,92 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.WriteLine($"Git Dirty : {map.GitIsDirty}"); if (!map.GraphTableAvailable) Console.WriteLine("WARN : symbol_references table missing — reference counts are synthesized 0. Do not use ReferenceRich / reference-derived ranking as authoritative."); - WriteRepoMapSection("Languages", map.Languages.Select(item => $"{item.Lang,-12} {item.Files,4} files {item.Symbols,5} syms {item.References,5} refs")); - WriteRepoMapSection("Modules", map.Modules.Select(item => $"{item.Module,-24} {item.Files,4} files {item.Symbols,5} syms {item.References,5} refs")); - WriteRepoMapSection("Top files", map.TopFiles.Select(item => $"{item.Path} [score {item.Score}, {item.SymbolCount} syms, {item.ReferenceCount} refs]")); - WriteRepoMapSection("Largest files", map.LargestFiles.Select(item => + if (MapSectionEnabled(options, "languages")) + WriteRepoMapSection("Languages", map.Languages.Select(item => $"{item.Lang,-12} {item.Files,4} files {item.Symbols,5} syms {item.References,5} refs")); + if (MapSectionEnabled(options, "tree")) + WriteRepoMapSection("Modules", map.Modules.Select(item => $"{item.Module,-24} {item.Files,4} files {item.Symbols,5} syms {item.References,5} refs")); + if (MapSectionEnabled(options, "hotspots")) + { + WriteRepoMapSection("Top files", map.TopFiles.Select(item => $"{item.Path} [score {item.Score}, {item.SymbolCount} syms, {item.ReferenceCount} refs]")); + WriteRepoMapSection("Symbol-rich files", map.SymbolRichFiles.Select(item => $"{item.Path} [{item.SymbolCount} syms, {item.ReferenceCount} refs]")); + WriteRepoMapSection("Reference-rich files", map.ReferenceRichFiles.Select(item => $"{item.Path} [{item.ReferenceCount} refs, {item.SymbolCount} syms]")); + WriteRepoMapSection("Entrypoints", map.Entrypoints.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.Line} [score {item.Score}, confidence {item.Confidence:0.###}, {item.MatchType}, hint #{item.HintRank}]")); + } + if (MapSectionEnabled(options, "metrics")) + WriteRepoMapSection("Largest files", map.LargestFiles.Select(item => { var size = options.RawBytes ? $"{item.Size.ToString(CultureInfo.InvariantCulture)} bytes" : ConsoleUi.FormatBytes(item.Size); return $"{item.Path} [{item.Lines} lines, {size}]"; })); - WriteRepoMapSection("Symbol-rich files", map.SymbolRichFiles.Select(item => $"{item.Path} [{item.SymbolCount} syms, {item.ReferenceCount} refs]")); - WriteRepoMapSection("Reference-rich files", map.ReferenceRichFiles.Select(item => $"{item.Path} [{item.ReferenceCount} refs, {item.SymbolCount} syms]")); - WriteRepoMapSection("Entrypoints", map.Entrypoints.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.Line} [score {item.Score}, confidence {item.Confidence:0.###}, {item.MatchType}, hint #{item.HintRank}]")); } return CommandExitCodes.Success; }); } + private static bool MapSectionEnabled(QueryCommandOptions options, string section) + => options.MapSections == null || options.MapSections.Contains(section, StringComparer.Ordinal); + + private static void ApplyRepoMapDepth(RepoMapResult map, int depth) + { + map.Modules = map.Modules + .Where(module => GetPathDepth(module.Module) <= depth) + .ToList(); + } + + private static int GetPathDepth(string path) + => string.IsNullOrEmpty(path) ? 0 : path.Split('/', StringSplitOptions.RemoveEmptyEntries).Length; + + private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryCommandOptions options, JsonSerializerOptions jsonOptions) + { + var payload = JsonSerializer.SerializeToNode(map, CliJsonSerializerContextFactory.Create(jsonOptions).RepoMapResult)!.AsObject(); + if (options.MapSections == null) + { + if (options.ContextAfterExplicit) + payload["depth"] = options.ContextAfter; + return payload; + } + + var keep = new HashSet(StringComparer.Ordinal) + { + "api_version", + "fileCount", + "totalLines", + "totalSymbols", + "totalReferences", + "indexedAt", + "latestModified", + "workspaceIndexedAt", + "workspaceLatestModified", + "projectRoot", + "gitHead", + "gitIsDirty", + "indexed_head_commit", + "worktree_head_changed", + "graphTableAvailable", + }; + if (MapSectionEnabled(options, "languages")) + keep.Add("languages"); + if (MapSectionEnabled(options, "tree")) + keep.Add("modules"); + if (MapSectionEnabled(options, "hotspots")) + { + keep.Add("topFiles"); + keep.Add("symbolRichFiles"); + keep.Add("referenceRichFiles"); + keep.Add("entrypoints"); + } + if (MapSectionEnabled(options, "metrics")) + keep.Add("largestFiles"); + + foreach (var propertyName in payload.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) + payload.Remove(propertyName); + payload["sections"] = new JsonArray(options.MapSections.Select(section => JsonValue.Create(section)).ToArray()); + if (options.ContextAfterExplicit) + payload["depth"] = options.ContextAfter; + return payload; + } + public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var previewOptionError = ValidatePreviewOptions("inspect", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); @@ -3504,18 +3582,47 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) return ZeroResultExitCode(options); } + List> cycles = []; + var outputEdges = options.DependencyCycles ? FilterCycleEdges(results, out cycles) : results; + if (options.DependencyCycles && cycles.Count == 0) + { + if (options.Json) + Console.WriteLine(new JsonObject { ["count"] = 0, ["cycles"] = new JsonArray() }.ToJsonString(jsonOptions)); + else + Console.Error.WriteLine(BuildZeroResultLine("No dependency cycles found", options)); + return ZeroResultExitCode(options); + } + + if (options.OutputFormat is OutputFormatDot or OutputFormatGraphMl or OutputFormatJsonGraph) + { + WriteDependencyGraph(outputEdges, options.OutputFormat, jsonOptions); + return CommandExitCodes.Success; + } + if (options.Json) { var payload = new JsonObject { - ["count"] = results.Count, - ["edges"] = JsonSerializer.SerializeToNode(results, CliJsonSerializerContextFactory.Create(jsonOptions).ListFileDependencyResult) + ["count"] = options.DependencyCycles ? cycles.Count : results.Count, }; + if (options.DependencyCycles) + payload["cycles"] = BuildDependencyCyclesJson(cycles); + else + payload["edges"] = JsonSerializer.SerializeToNode(results, CliJsonSerializerContextFactory.Create(jsonOptions).ListFileDependencyResult); AddSqlGraphContractJsonFields(payload, sqlGraphSignal); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else { + if (options.DependencyCycles) + { + foreach (var cycle in cycles) + Console.WriteLine(string.Join(" -> ", cycle.Concat([cycle[0]]))); + Console.Error.WriteLine($"({cycles.Count} dependency cycles)"); + WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); + return CommandExitCodes.Success; + } + foreach (var r in results) { var syms = r.Symbols.Length > 60 ? r.Symbols[..57] + "..." : r.Symbols; @@ -3528,6 +3635,123 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } + internal static List FilterCycleEdges(List results, out List> cycles) + { + cycles = FindDependencyCycles(results); + if (cycles.Count == 0) + return []; + var cycleNodes = cycles.SelectMany(cycle => cycle).ToHashSet(StringComparer.Ordinal); + return results + .Where(edge => cycleNodes.Contains(edge.SourcePath) && cycleNodes.Contains(edge.TargetPath)) + .ToList(); + } + + internal static List> FindDependencyCycles(IReadOnlyList edges) + { + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (!adjacency.TryGetValue(edge.SourcePath, out var targets)) + adjacency[edge.SourcePath] = targets = []; + targets.Add(edge.TargetPath); + adjacency.TryAdd(edge.TargetPath, []); + } + + var index = 0; + var stack = new Stack(); + var onStack = new HashSet(StringComparer.Ordinal); + var indexes = new Dictionary(StringComparer.Ordinal); + var lowLinks = new Dictionary(StringComparer.Ordinal); + var cycles = new List>(); + + void Visit(string node) + { + indexes[node] = index; + lowLinks[node] = index; + index++; + stack.Push(node); + onStack.Add(node); + + foreach (var target in adjacency[node]) + { + if (!indexes.ContainsKey(target)) + { + Visit(target); + lowLinks[node] = Math.Min(lowLinks[node], lowLinks[target]); + } + else if (onStack.Contains(target)) + { + lowLinks[node] = Math.Min(lowLinks[node], indexes[target]); + } + } + + if (lowLinks[node] != indexes[node]) + return; + + var component = new List(); + string popped; + do + { + popped = stack.Pop(); + onStack.Remove(popped); + component.Add(popped); + } while (!string.Equals(popped, node, StringComparison.Ordinal)); + + var selfCycle = component.Count == 1 && adjacency[component[0]].Contains(component[0], StringComparer.Ordinal); + if (component.Count > 1 || selfCycle) + cycles.Add(component.OrderBy(path => path, StringComparer.Ordinal).ToList()); + } + + foreach (var node in adjacency.Keys.OrderBy(path => path, StringComparer.Ordinal).ToList()) + if (!indexes.ContainsKey(node)) + Visit(node); + + return cycles; + } + + internal static JsonArray BuildDependencyCyclesJson(IReadOnlyList> cycles) + { + var array = new JsonArray(); + foreach (var cycle in cycles) + { + array.Add(new JsonObject + { + ["length"] = cycle.Count, + ["nodes"] = new JsonArray(cycle.Select(node => JsonValue.Create(node)).ToArray()) + }); + } + return array; + } + + private static void WriteDependencyGraph(IReadOnlyList edges, string format, JsonSerializerOptions jsonOptions) + { + switch (format) + { + case OutputFormatDot: + Console.WriteLine("digraph deps {"); + foreach (var edge in edges) + Console.WriteLine($" \"{EscapeDot(edge.SourcePath)}\" -> \"{EscapeDot(edge.TargetPath)}\" [label=\"{edge.ReferenceCount}\"];"); + Console.WriteLine("}"); + break; + case OutputFormatGraphMl: + Console.WriteLine(""); + Console.WriteLine(""); + foreach (var node in edges.SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }).Distinct(StringComparer.Ordinal)) + Console.WriteLine($""); + foreach (var edge in edges) + Console.WriteLine($"{edge.ReferenceCount}"); + Console.WriteLine(""); + break; + case OutputFormatJsonGraph: + var nodes = edges.SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }).Distinct(StringComparer.Ordinal).Select(path => new JsonObject { ["id"] = path }).ToArray(); + var graphEdges = edges.Select(edge => new JsonObject { ["source"] = edge.SourcePath, ["target"] = edge.TargetPath, ["reference_count"] = edge.ReferenceCount }).ToArray(); + Console.WriteLine(new JsonObject { ["nodes"] = new JsonArray(nodes), ["edges"] = new JsonArray(graphEdges) }.ToJsonString(jsonOptions)); + break; + } + } + + private static string EscapeDot(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + 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); @@ -4613,6 +4837,8 @@ public static QueryCommandOptions ParseArgs( var rankMode = ReferenceRankMode.Weighted; var extraNames = new List(); bool impactDeprecatedDepthUsed = false; + List? mapSections = null; + bool dependencyCycles = false; void AddParseError(string error) { @@ -4752,12 +4978,14 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) { outputFormat = parsedOutputFormat; - if (parsedOutputFormat != OutputFormatText) + if (parsedOutputFormat != OutputFormatText && + parsedOutputFormat != OutputFormatDot && + parsedOutputFormat != OutputFormatGraphMl) json = true; } else { - AddParseError($"Error: --format must be one of text, json, count, compact, csv, tsv, lsp, qf, or sarif; got '{formatValue}'."); + AddParseError($"Error: --format must be one of text, json, count, compact, csv, tsv, lsp, qf, sarif, dot, graphml, json-graph, or edgelist; got '{formatValue}'."); } } else @@ -4847,6 +5075,15 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(rankByError!); break; + case "--sections": + if (TryReadStringOptionValue(args, ref i, "--sections", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sectionsValue, out var sectionsError)) + { + WarnIfDuplicateSingleValueOption("--sections", sectionsValue!); + mapSections = ParseMapSections(sectionsValue!, AddParseError); + } + else + AddParseError(sectionsError!); + break; case "--fts": rawFts = true; break; @@ -4856,6 +5093,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--count": countOnly = true; break; + case "--cycles": + dependencyCycles = true; + break; case "--strict-not-found": strictNotFound = true; break; @@ -5301,10 +5541,40 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) StatusConfig = statusConfig, RankMode = rankMode, ExtraNames = extraNames, + MapSections = mapSections, + DependencyCycles = dependencyCycles, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), }; } + private static List ParseMapSections(string rawValue, Action addParseError) + { + var sections = new List(); + foreach (var rawSection in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var section = rawSection.ToLowerInvariant(); + switch (section) + { + case "tree": + case "modules": + sections.Add("tree"); + break; + case "languages": + case "hotspots": + case "metrics": + sections.Add(section); + break; + default: + addParseError($"Error: --sections contains unsupported section '{rawSection}'. Use one or more of tree, languages, hotspots, metrics."); + break; + } + } + + if (sections.Count == 0) + addParseError("Error: --sections cannot be empty. Use one or more of tree, languages, hotspots, metrics."); + return sections.Distinct(StringComparer.Ordinal).ToList(); + } + private static void ValidateQueryPathOptionValues( IReadOnlyList pathPatterns, IReadOnlyList excludePaths, @@ -5346,6 +5616,10 @@ private static bool TryParseOutputFormat(string rawValue, out string format) case OutputFormatLsp: case OutputFormatQf: case OutputFormatSarif: + case OutputFormatDot: + case OutputFormatGraphMl: + case OutputFormatJsonGraph: + case OutputFormatEdgeList: format = rawValue.ToLowerInvariant(); return true; default: @@ -7550,6 +7824,7 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--stale-after"] = "pass a compact positive duration, e.g. `--stale-after 30m`, `--stale-after 2h`, or `--stale-after 7d`.", ["--slow-query-ms"] = "pass a non-negative millisecond threshold, e.g. `--slow-query-ms 500`; use 0 to log every profiled SQL statement.", ["--min-entrypoint-confidence"] = "pass a decimal from 0.0 through 1.0, e.g. `--min-entrypoint-confidence 0.6`.", + ["--sections"] = "pass a comma-separated map section list, e.g. `--sections tree,languages`. Supported sections: tree, languages, hotspots, metrics.", }; // Build a missing-value error string with optional caller-supplied hint lines first, then the @@ -7930,5 +8205,7 @@ public sealed class QueryCommandOptions public bool StatusConfig { get; init; } public ReferenceRankMode RankMode { get; init; } = ReferenceRankMode.Weighted; public List ExtraNames { get; init; } = []; + public List? MapSections { get; init; } + public bool DependencyCycles { get; init; } public string? ParseError { get; init; } } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 5e167e09e5..99901d80bb 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -244,7 +244,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "map", - "Return a repo-level overview with languages, modules, top files, and likely entrypoints. / 言語、モジュール、主要ファイル、推定エントリポイントを含むリポジトリ俯瞰情報を返す。", + "Return a repo-level overview with selectable sections (`tree`, `languages`, `hotspots`, `metrics`) and optional module depth control. / セクション選択(`tree`, `languages`, `hotspots`, `metrics`)とモジュール深さ制御に対応したリポジトリ俯瞰情報を返す。", new JsonObject { ["type"] = "object", @@ -254,7 +254,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false } + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["sections"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "tree", "languages", "hotspots", "metrics" } }, ["description"] = "Only include selected response sections. Omit for the full backward-compatible map." }, + ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = "Maximum module/tree depth to include; 0 keeps only root-level modules.", ["minimum"] = 0 } } }, ReadOnlyAnnotations()), @@ -328,7 +330,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "deps", - "Show file-level dependency edges from the indexed reference graph. / インデックス済み参照グラフからファイル間の依存エッジを返す。", + "Show file-level dependency edges, JSON graph payloads, or dependency cycles from the indexed reference graph. / インデックス済み参照グラフからファイル間の依存エッジ、JSON graph ペイロード、依存サイクルを返す。", new JsonObject { ["type"] = "object", @@ -340,7 +342,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false } + ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false }, + ["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 dependency cycles instead of ordinary edge rows.", ["default"] = false } } }, ReadOnlyAnnotations()), diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3c07ae5c59..31041ffa75 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1528,12 +1528,33 @@ private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var sections = ReadStringList(args, "sections").Select(section => section.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); + var depth = args?["depth"]?.GetValue(); return WithDbReader(id, args, reader => { var map = reader.GetRepoMap(limit, lang, pathPatterns, excludePaths, excludeTests); WorkspaceMetadataEnricher.Enrich(map, _dbPath, _dbPathExplicit); var structured = JsonSerializer.SerializeToNode(map, _jsonOptions)!.AsObject(); + if (depth is >= 0) + { + var modules = structured["modules"] as JsonArray; + if (modules != null) + { + var kept = new JsonArray(modules + .Where(node => + { + var module = node?["module"]?.GetValue() ?? string.Empty; + return module.Split('/', StringSplitOptions.RemoveEmptyEntries).Length <= depth.Value; + }) + .Select(node => node!.DeepClone()) + .ToArray()); + structured["modules"] = kept; + } + structured["depth"] = depth.Value; + } + if (sections.Count > 0) + ApplyMapSectionFilter(structured, sections); structured["limit"] = limit; structured["lang"] = lang; structured["path"] = PathEcho(pathPatterns); @@ -1548,6 +1569,33 @@ private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) }); } + private static void ApplyMapSectionFilter(JsonObject structured, IReadOnlySet sections) + { + var keep = new HashSet(StringComparer.Ordinal) + { + "api_version", "fileCount", "totalLines", "totalSymbols", "totalReferences", + "indexedAt", "latestModified", "workspaceIndexedAt", "workspaceLatestModified", + "projectRoot", "gitHead", "gitIsDirty", "indexed_head_commit", "worktree_head_changed", + "graphTableAvailable", "limit", "lang", "path", "excludeTests", "depth", + }; + if (sections.Contains("languages")) + keep.Add("languages"); + if (sections.Contains("tree") || sections.Contains("modules")) + keep.Add("modules"); + if (sections.Contains("hotspots")) + { + keep.Add("topFiles"); + keep.Add("symbolRichFiles"); + keep.Add("referenceRichFiles"); + keep.Add("entrypoints"); + } + if (sections.Contains("metrics")) + keep.Add("largestFiles"); + foreach (var key in structured.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) + structured.Remove(key); + structured["sections"] = new JsonArray(sections.Select(section => JsonValue.Create(section)).ToArray()); + } + private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) { if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) @@ -2621,6 +2669,8 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var reverse = args?["reverse"]?.GetValue() ?? false; + var cyclesOnly = args?["cycles"]?.GetValue() ?? false; + var format = args?["format"]?.GetValue()?.ToLowerInvariant() ?? "edgelist"; return WithDbReader(id, args, reader => { @@ -2633,14 +2683,19 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) baseSqlGraphSignal, results.SelectMany(result => new[] { result.SourcePath, result.TargetPath }), lang); - var payload = new JsonObject - { - ["count"] = results.Count, - ["edges"] = JsonSerializer.SerializeToNode(results, _jsonOptions) - }; + 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); + else if (format == "json-graph") + payload["graph"] = BuildJsonGraphPayload(outputEdges); + else + payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, _jsonOptions); + payload["format"] = format; AddSqlGraphContractSignal(payload, sqlGraphSignal); - var summary = results.Count > 0 - ? $"Found {ConsoleUi.Counted(results.Count, "dependency edge")}." + var summary = payload["count"]!.GetValue() > 0 + ? cyclesOnly ? $"Found {ConsoleUi.Counted(cycles.Count, "dependency cycle")}." : $"Found {ConsoleUi.Counted(results.Count, "dependency edge")}." : "No file dependencies found."; if (results.Count == 0) AddFreshnessHint(payload, reader); @@ -2648,6 +2703,19 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) }); } + private static JsonObject BuildJsonGraphPayload(IReadOnlyList edges) + { + var nodes = edges + .SelectMany(edge => new[] { edge.SourcePath, edge.TargetPath }) + .Distinct(StringComparer.Ordinal) + .Select(path => new JsonObject { ["id"] = path }) + .ToArray(); + var graphEdges = edges + .Select(edge => new JsonObject { ["source"] = edge.SourcePath, ["target"] = edge.TargetPath, ["reference_count"] = edge.ReferenceCount }) + .ToArray(); + return new JsonObject { ["nodes"] = new JsonArray(nodes), ["edges"] = new JsonArray(graphEdges) }; + } + private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) { if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 7b8da58d61..d6d280b6a9 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -12596,6 +12596,53 @@ public void RunUnused_CountJson_MixedRepoStaleSqlGraphContractIncludesDegradedSt } } + [Fact] + public void RunMap_ParseSectionsAndDepth_StoresSelectors() + { + var options = QueryCommandRunner.ParseArgs( + ["--json", "--sections", "tree,languages", "--depth", "2"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.True(options.Json); + Assert.Equal(["tree", "languages"], options.MapSections); + Assert.True(options.ContextAfterExplicit); + Assert.Equal(2, options.ContextAfter); + Assert.Null(options.ParseError); + } + + [Fact] + public void RunDeps_ParseGraphOptions_StoresFormatAndCycles() + { + var options = QueryCommandRunner.ParseArgs( + ["--format", "json-graph", "--cycles"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.Equal("json-graph", options.OutputFormat); + Assert.True(options.Json); + Assert.True(options.DependencyCycles); + Assert.Null(options.ParseError); + } + + [Fact] + public void FindDependencyCycles_ReturnsStronglyConnectedFileComponents() + { + var edges = new List + { + new() { SourcePath = "a.cs", TargetPath = "b.cs", ReferenceCount = 1 }, + new() { SourcePath = "b.cs", TargetPath = "a.cs", ReferenceCount = 1 }, + new() { SourcePath = "c.cs", TargetPath = "d.cs", ReferenceCount = 1 }, + }; + + var cycles = QueryCommandRunner.FindDependencyCycles(edges); + + var cycle = Assert.Single(cycles); + Assert.Equal(["a.cs", "b.cs"], cycle); + } + [Fact] public void RunDeps_ZeroJson_StaleSqlGraphContractIncludesDegradedStateWhenSqlScopeIsEmpty() { From 61733238e78d19e6d642091346cc21192f3eba83 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 20:47:05 +0900 Subject: [PATCH 2/2] Tighten deps format parsing for issues #1634 #1635 --- src/CodeIndex/Cli/QueryCommandRunner.cs | 87 +++++++++++++++++-- .../QueryCommandRunnerTests.cs | 9 +- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 12ecf36cba..71568dba9c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2349,8 +2349,14 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } + if (!TryExtractDepsFormat(cmdArgs, out var depsFormat, out var parseArgs, out var depsFormatError)) + { + Console.Error.WriteLine(depsFormatError); + return CommandExitCodes.UsageError; + } + var options = ParseArgs( - cmdArgs, + parseArgs, jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); @@ -3543,8 +3549,14 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } + if (!TryExtractDepsFormat(cmdArgs, out var depsFormat, out var parseArgs, out var depsFormatError)) + { + Console.Error.WriteLine(depsFormatError); + return CommandExitCodes.UsageError; + } + var options = ParseArgs( - cmdArgs, + parseArgs, jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); @@ -3593,9 +3605,9 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) return ZeroResultExitCode(options); } - if (options.OutputFormat is OutputFormatDot or OutputFormatGraphMl or OutputFormatJsonGraph) + if (depsFormat is OutputFormatDot or OutputFormatGraphMl or OutputFormatJsonGraph) { - WriteDependencyGraph(outputEdges, options.OutputFormat, jsonOptions); + WriteDependencyGraph(outputEdges, depsFormat, jsonOptions); return CommandExitCodes.Success; } @@ -3635,6 +3647,67 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } + private static bool TryExtractDepsFormat(string[] args, out string format, out string[] parseArgs, out string? error) + { + format = OutputFormatEdgeList; + error = null; + var rewritten = new List(args.Length); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg.StartsWith("--format=", StringComparison.Ordinal)) + { + var rawFormat = arg["--format=".Length..]; + if (!TryNormalizeDepsFormat(rawFormat, out format, out error)) + { + parseArgs = args; + return false; + } + rewritten.Add(format == OutputFormatJsonGraph ? "--format=json" : "--format=text"); + continue; + } + + if (arg == "--format" && i + 1 < args.Length) + { + var rawFormat = args[++i]; + if (!TryNormalizeDepsFormat(rawFormat, out format, out error)) + { + parseArgs = args; + return false; + } + rewritten.Add("--format"); + rewritten.Add(format == OutputFormatJsonGraph ? "json" : "text"); + continue; + } + + rewritten.Add(arg); + } + + parseArgs = rewritten.ToArray(); + return true; + } + + private static bool TryNormalizeDepsFormat(string rawFormat, out string format, out string? error) + { + format = rawFormat.ToLowerInvariant(); + error = null; + switch (format) + { + case OutputFormatText: + case OutputFormatJson: + case OutputFormatEdgeList: + format = OutputFormatEdgeList; + return true; + case OutputFormatDot: + case OutputFormatGraphMl: + case OutputFormatJsonGraph: + return true; + default: + error = $"Error: deps --format must be one of edgelist, dot, graphml, or json-graph; got '{rawFormat}'."; + return false; + } + } + internal static List FilterCycleEdges(List results, out List> cycles) { cycles = FindDependencyCycles(results); @@ -4985,7 +5058,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } else { - AddParseError($"Error: --format must be one of text, json, count, compact, csv, tsv, lsp, qf, sarif, dot, graphml, json-graph, or edgelist; got '{formatValue}'."); + AddParseError($"Error: --format must be one of text, json, count, compact, csv, tsv, lsp, qf, or sarif; got '{formatValue}'."); } } else @@ -5616,10 +5689,6 @@ private static bool TryParseOutputFormat(string rawValue, out string format) case OutputFormatLsp: case OutputFormatQf: case OutputFormatSarif: - case OutputFormatDot: - case OutputFormatGraphMl: - case OutputFormatJsonGraph: - case OutputFormatEdgeList: format = rawValue.ToLowerInvariant(); return true; default: diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index d6d280b6a9..1e53403b48 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -12613,18 +12613,15 @@ public void RunMap_ParseSectionsAndDepth_StoresSelectors() } [Fact] - public void RunDeps_ParseGraphOptions_StoresFormatAndCycles() + public void ParseArgs_GraphFormatOutsideDeps_ReturnsParseError() { var options = QueryCommandRunner.ParseArgs( - ["--format", "json-graph", "--cycles"], + ["--format", "json-graph"], jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); - Assert.Equal("json-graph", options.OutputFormat); - Assert.True(options.Json); - Assert.True(options.DependencyCycles); - Assert.Null(options.ParseError); + Assert.Contains("--format must be one of text", options.ParseError); } [Fact]