diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6149a6a45d..8678ae6be8 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -302,11 +302,24 @@ Most query commands emit one complete JSON value when `--json` is set. `search per line as newline-delimited JSON (ndjson), then a final `{"done":true,...}` line. Stream consumers can parse each line as it arrives; array-oriented tools can use `jq -s '.'` or pass `--json=array` to `search` to emit the result set as -one JSON array. +one JSON array. Add `--pretty` with `--json` to indent single-document JSON +responses; for `search`, use `--json=array --pretty` when the result set itself +should be indented because default `search --json` stays newline-delimited. +For AI-oriented bounded payloads, `map`, `inspect`, and `outline` accept +`--compact`. It implies JSON output, caps list sections to 5 items by default +(or the explicit `--limit` / `--top` value), and adds `compact`, +`compact_limit`, and `truncation.sections.*` metadata. +For narrower `inspect` evidence, `--fields ` implies JSON and selects +top-level groups such as `definitions`, `file`, `graph`, `references`, +`callers`, and `callees`; `--body-only` is shorthand for `--body --fields +definitions`. ```bash cdidx search authenticate --json # ndjson stream, one result per line cdidx search authenticate --json=array # single JSON array +cdidx inspect QueryCommandRunner --json --pretty +cdidx map --compact # capped JSON with truncation metadata +cdidx inspect Compute --body-only # definitions with body_content only ``` For `cdidx find --count --json`, `files` is the canonical matched-file count. @@ -1194,6 +1207,10 @@ same source location. |---|---|---| | `--db ` | All commands except `languages`; for `mcp`, only `--db` is supported | Database file path. `index` defaults to `/.cdidx/codeindex.db`; query commands default to `.cdidx/codeindex.db` in the current directory. Query commands without `--db` keep trusting that default `.cdidx/codeindex.db` sibling path, so moving or renaming the current repo does not leave stale workspace metadata behind. For explicit query DBs, workspace metadata such as `project_root`, `git_head`, and `git_is_dirty` comes from the persisted `indexed_project_root` stored in that DB when available. Legacy explicit DBs created before that metadata existed may return those fields as `null` / absent until you rerun `cdidx index --db ` or a scoped update that actually commits at least one file delete/update against the intended project, even if the explicit path itself looks like `.../.cdidx/codeindex.db`. | | `--json` | All commands except `mcp` | JSON output (for AI/machine use). `search --json` writes newline-delimited result objects followed by a final `{"done":true,"count":N,"interrupted":false}` sentinel, including zero-result output, so stream consumers can detect clean completion. | +| `--pretty` | JSON-capable commands except `mcp` | Pretty-print JSON output with indentation. Default `search --json` remains newline-delimited; use `search --json=array --pretty` for an indented search result array. | +| `--compact` | `map`, `inspect`, `outline` | Emit AI-oriented compact JSON with capped list sections and `truncation.sections.*` metadata. The default cap is 5 unless `--limit` / `--top` is supplied. | +| `--fields ` | `inspect` | Select top-level inspect JSON groups: `file`, `workspace`, `graph`, `definitions`, `body`, `nearby_symbols`, `references`, `callers`, `callees`, or `all`. `body` includes definition bodies and maps to `definitions`. | +| `--body-only` | `inspect` | Shorthand for `--body --fields definitions`, useful when large audits need implementation text without graph context. | | `--status ` | `suggestions` | Filter local suggestion history by GitHub submission state. | | `--language ` / `--lang ` | `suggestions` | Filter local suggestion history by recorded target language. | | `--category ` | `suggestions` | Filter local suggestion history by suggestion category. | @@ -2537,11 +2554,25 @@ release changelog を source of truth とします。完全な syntax line は ` newline-delimited JSON (ndjson) として出力し、最後に `{"done":true,...}` 行を 出力します。stream consumer は各行を到着順に parse できます。array 前提の tool では `jq -s '.'` を使うか、`search` に `--json=array` を渡すと result set を -1 つの JSON array として出力できます。 +1 つの JSON array として出力できます。`--json` と一緒に `--pretty` を付けると +単一 document の JSON 応答をインデント付きで出力します。`search` の result set を +整形したい場合は、既定の `search --json` が newline-delimited のまま保たれるため +`--json=array --pretty` を使います。 +AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outline` は +`--compact` に対応しています。これは JSON 出力を暗黙に有効化し、list section を +既定 5 件(明示した `--limit` / `--top` があればその値)に cap し、 +`compact`、`compact_limit`、`truncation.sections.*` metadata を追加します。 +`inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 +`definitions`、`file`、`graph`、`references`、`callers`、`callees` などの +top-level group を選択します。`--body-only` は `--body --fields definitions` の +shorthand です。 ```bash cdidx search authenticate --json # ndjson stream、1 行 1 result cdidx search authenticate --json=array # 単一 JSON array +cdidx inspect QueryCommandRunner --json --pretty +cdidx map --compact # truncation metadata 付きの cap 済み JSON +cdidx inspect Compute --body-only # body_content 付き definitions のみ ``` ## Editor / index portability @@ -3428,6 +3459,10 @@ raw match density を正確に測る、といった理由で全 raw chunk hit |---|---|---| | `--db ` | `languages` を除く全コマンド。`mcp` は `--db` のみ対応 | DBファイルパス。`index` のデフォルトは `/.cdidx/codeindex.db`、クエリ系コマンドのデフォルトはカレントディレクトリの `.cdidx/codeindex.db`。`--db` を付けない query は、その既定の `.cdidx/codeindex.db` sibling path を引き続き正とするため、カレント repo を move/rename しても古い workspace metadata を引きずらない。明示指定 query DB の `project_root`、`git_head`、`git_is_dirty` などの workspace metadata は、利用可能な場合はその DB に保存された `indexed_project_root` から解決される。保存前の古い explicit DB では、意図した project に対して `cdidx index --db `、または少なくとも 1 件の file delete/update を実際に commit する scoped update を一度実行するまで、これらの項目が `null` / 未出力になることがあり、明示パス自体が `.../.cdidx/codeindex.db` でも同じ。 | | `--json` | `mcp` を除く全コマンド | JSON出力(AI/機械向け) | +| `--pretty` | `mcp` を除く JSON 対応コマンド | JSON 出力をインデント付きで整形。既定の `search --json` は newline-delimited のまま維持されるため、検索結果配列を整形したい場合は `search --json=array --pretty` を使う。 | +| `--compact` | `map`、`inspect`、`outline` | list section を cap した AI 向け compact JSON を出力し、`truncation.sections.*` metadata を含める。既定 cap は 5 件で、`--limit` / `--top` 指定時はその値を使う。 | +| `--fields ` | `inspect` | inspect JSON の top-level group を選択。`file`、`workspace`、`graph`、`definitions`、`body`、`nearby_symbols`、`references`、`callers`、`callees`、`all` を指定できる。`body` は definition body を含め、`definitions` に対応する。 | +| `--body-only` | `inspect` | `--body --fields definitions` の shorthand。大規模 audit で graph context なしに実装本文だけが必要な場合に使う。 | | `--status ` | `suggestions` | ローカル提案履歴を GitHub 送信状態で絞り込みます。 | | `--language ` / `--lang ` | `suggestions` | ローカル提案履歴を記録済み対象言語で絞り込みます。 | | `--category ` | `suggestions` | ローカル提案履歴を提案カテゴリで絞り込みます。 | diff --git a/changelog.d/unreleased/2996.added.md b/changelog.d/unreleased/2996.added.md new file mode 100644 index 0000000000..097b640a80 --- /dev/null +++ b/changelog.d/unreleased/2996.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 2996 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **JSON commands can now pretty-print output (#2996)** — `--pretty` enables indented JSON for commands that share the CLI JSON serializer, making large JSON payloads easier to inspect and diff. + +## 日本語 + +- **JSON コマンドが整形出力に対応しました (#2996)** — `--pretty` により CLI の JSON serializer を共有するコマンドでインデント付き JSON を出力でき、大きな JSON payload の確認や diff がしやすくなりました。 diff --git a/changelog.d/unreleased/3009.added.md b/changelog.d/unreleased/3009.added.md new file mode 100644 index 0000000000..79985353a9 --- /dev/null +++ b/changelog.d/unreleased/3009.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 3009 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - USER_GUIDE.md +--- + +## English + +- **`map`, `inspect`, and `outline` now support compact JSON section caps (#3009)** — `--compact` emits AI-oriented JSON, caps list sections to 5 items by default or the explicit `--limit` / `--top` value, and adds `truncation.sections.*` metadata for bounded payloads. + +## 日本語 + +- **`map`、`inspect`、`outline` が compact JSON section cap に対応しました (#3009)** — `--compact` は AI 向け JSON を出力し、list section を既定 5 件または明示した `--limit` / `--top` 値で cap し、上限付き payload 用の `truncation.sections.*` metadata を追加します。 diff --git a/changelog.d/unreleased/3056.added.md b/changelog.d/unreleased/3056.added.md new file mode 100644 index 0000000000..f1d8530358 --- /dev/null +++ b/changelog.d/unreleased/3056.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 3056 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - USER_GUIDE.md +--- + +## English + +- **`inspect --json` can now select compact evidence fields (#3056)** — `--fields ` emits only requested top-level evidence groups, while `--body-only` is a shortcut for definition bodies without graph context. + +## 日本語 + +- **`inspect --json` が compact evidence field selection に対応しました (#3056)** — `--fields ` は要求した top-level evidence group だけを出力し、`--body-only` は graph context なしで definition body を取得する shortcut として使えます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index b45fffc87a..2fea02d836 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -75,7 +75,7 @@ internal static class CliFlagSchema private static readonly string[] LimitCapableCommands = [ "search", "definition", "goto", "references", "callers", "callees", "symbols", - "files", "find", "map", "inspect", "deps", "impact", "unused", "hotspots", "validate", + "files", "find", "map", "inspect", "outline", "deps", "impact", "unused", "hotspots", "validate", ]; private static readonly string[] LangCapableCommands = @@ -151,6 +151,7 @@ internal static class CliFlagSchema ]; private static readonly string[] BodyCommands = ["definition", "references", "callers", "callees", "impact", "inspect"]; + private static readonly string[] InspectFieldCommands = ["inspect"]; private static readonly string[] MaxLineWidthCommands = [ @@ -187,6 +188,8 @@ internal static class CliFlagSchema "validate", "deps", "impact", "unused", "hotspots", "languages", "db", "report", ]; + private static readonly string[] CompactJsonCommands = ["map", "inspect", "outline"]; + private static readonly string[] FormatCommands = [ "search", "definition", "references", "callers", "callees", "find", "validate", "deps", @@ -214,6 +217,8 @@ private static IReadOnlyList BuildAll() 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/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) }, + new() { Name = "--compact", Description = "AI-oriented compact JSON with capped list sections and truncation metadata", Commands = Set(CompactJsonCommands) }, new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI; search recipes also accept issue-drafts", Commands = Set(FormatCommands) }, new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) }, @@ -256,6 +261,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "", Description = "Search: preflight issue drafts against open issue JSON", Commands = Set("search") }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, + new() { Name = "--fields", ValuePlaceholder = "", Description = "Inspect: select top-level JSON evidence groups", Commands = Set(InspectFieldCommands) }, + new() { Name = "--body-only", Description = "Inspect: JSON shorthand for --body --fields definitions", Commands = Set(InspectFieldCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, new() { Name = "--regex", Description = "Use regular expression matching", Commands = Set("find") }, new() { Name = "--exact-name", Description = "Exact symbol-name equality", Commands = Set(ExactNameCommands), AlsoAcceptedBy = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 4fa0b1708e..47d3dc4bd3 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -78,7 +78,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), + ("search", "cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), ("definition", "cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), @@ -88,9 +88,9 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("files", "cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--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] [--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]"), + ("map", "cdidx map [--db ] [--json] [--pretty] [--compact] [--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] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), + ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), ("workspace", "cdidx workspace [name] [--json]"), ("config", "cdidx config show [--json]"), diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 125db8b2e7..3b083f1fef 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -38,6 +38,7 @@ internal static class ProgramRunner "--metrics", "--debug-unsafe", "--strict-version", + "--pretty", }; private static readonly HashSet TopLevelValueOptionNames = new(StringComparer.Ordinal) { @@ -128,6 +129,8 @@ internal static int Run( CommandErrorWriter.Write(StripErrorPrefix(strictVersionError), "use `--strict-version` without a value."); return CommandExitCodes.InvalidArgument; } + if (TryConsumePrettyJsonFlag(ref args)) + jsonOptions = new JsonSerializerOptions(jsonOptions) { WriteIndented = true }; using var jsonAnsiScope = ConsoleUi.SuppressAnsiForJsonOutput(ContainsJsonOutputFlag(args)); var commandStopwatch = Stopwatch.StartNew(); @@ -926,6 +929,46 @@ internal static bool TryConsumeQuietFlag(ref string[] args) return quiet; } + internal static bool TryConsumePrettyJsonFlag(ref string[] args) + { + if (args.Length == 0) + return false; + + var kept = new List(args.Length); + var pretty = false; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--pretty") + { + pretty = true; + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + return pretty; + } + internal static bool TryConsumeGlobalLogFlags(ref string[] args, out string error) { error = string.Empty; @@ -1592,8 +1635,7 @@ private static int CheckWorkspaceVersionPin(string appVersion, string startDirec if (pinPath == null) return CommandExitCodes.Success; - string required; - if (!TryReadWorkspaceVersionPin(pinPath, out required, out var warning)) + if (!TryReadWorkspaceVersionPin(pinPath, out var required, out var warning)) { Console.Error.WriteLine(warning); return CommandExitCodes.Success; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 29ad65b862..855bba2197 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -21,6 +21,7 @@ public static class QueryCommandRunner { internal const int DefaultQueryLimit = 20; internal const int DefaultMapLimit = 10; + internal const int DefaultCompactSectionLimit = 5; internal const int DefaultImpactLimit = 50; internal const int BatchMaxLineChars = 1024 * 1024; internal const int BatchMaxArgumentCount = 256; @@ -51,6 +52,8 @@ public static class QueryCommandRunner internal const int MaxSymbolQueryNames = 256; internal const int MaxMapSectionsCsvLength = 256; internal const int MaxMapSectionsCsvEntries = 16; + internal const int MaxInspectFieldsCsvLength = 256; + internal const int MaxInspectFieldsCsvEntries = 16; internal const int MaxStatusCheckScopesCsvLength = 256; internal const int MaxStatusCheckScopesCsvEntries = 16; internal const int MaxVisibilityFilterCsvLength = 256; @@ -129,6 +132,7 @@ public static class QueryCommandRunner "--format", "--min-entrypoint-confidence", "--sections", + "--fields", ]; private sealed record StatusReadinessField( string FieldName, @@ -235,6 +239,9 @@ private sealed record StatusReadinessField( "--list-recipes", "--read-only", "--immutable", + "--pretty", + "--compact", + "--body-only", ]; private const string OutputFormatText = "text"; private const string OutputFormatJson = "json"; @@ -589,6 +596,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; var exactSubstringHint = SearchQueryAdvisor.BuildExactSubstringHint(options.Query, options.RawFts, exact, options.Prefix); + var ndjsonOptions = options.JsonOutputFormat == JsonOutputFormatNdjson ? GetCompactJsonOptions(jsonOptions) : jsonOptions; int? jsonDoneCount = null; return WithDb(options, jsonOptions, reader => { @@ -640,7 +648,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: "results", query: options.Query, ftsQueryDiagnostics: ftsQueryDiagnostics, queryOptions: options, exactSubstringHint: exactSubstringHint).ToJsonString(jsonOptions)); + Console.WriteLine(BuildJsonZeroResultPayload(reader, ndjsonOptions, resultsKey: "results", query: options.Query, ftsQueryDiagnostics: ftsQueryDiagnostics, queryOptions: options, exactSubstringHint: exactSubstringHint).ToJsonString(ndjsonOptions)); jsonDoneCount = 0; } } @@ -669,7 +677,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: "results", query: options.Query, ftsQueryDiagnostics: ftsQueryDiagnostics, queryOptions: options, exactSubstringHint: exactSubstringHint).ToJsonString(jsonOptions)); + Console.WriteLine(BuildJsonZeroResultPayload(reader, ndjsonOptions, resultsKey: "results", query: options.Query, ftsQueryDiagnostics: ftsQueryDiagnostics, queryOptions: options, exactSubstringHint: exactSubstringHint).ToJsonString(ndjsonOptions)); jsonDoneCount = 0; } } @@ -722,7 +730,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) foreach (var result in compactResults) Console.WriteLine(JsonSerializer.Serialize( result, - CliJsonSerializerContextFactory.Create(jsonOptions).CompactSearchResult)); + CliJsonSerializerContextFactory.Create(ndjsonOptions).CompactSearchResult)); jsonDoneCount = compactResults.Length; } } @@ -745,7 +753,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) }, exitCode => { if (options.Json && options.JsonOutputFormat == JsonOutputFormatNdjson && jsonDoneCount.HasValue) - WriteJsonStreamDone(jsonDoneCount.Value, jsonOptions); + WriteJsonStreamDone(jsonDoneCount.Value, ndjsonOptions); }); } @@ -1113,6 +1121,9 @@ private static void WriteJsonStreamDone(int count, JsonSerializerOptions jsonOpt new JsonStreamDoneResult(Done: true, Count: count, Interrupted: false), CliJsonSerializerContextFactory.Create(jsonOptions).JsonStreamDoneResult)); + private static JsonSerializerOptions GetCompactJsonOptions(JsonSerializerOptions jsonOptions) + => jsonOptions.WriteIndented ? new JsonSerializerOptions(jsonOptions) { WriteIndented = false } : jsonOptions; + public static void AttachLspLocations(IEnumerable results) { foreach (var result in results) @@ -2969,10 +2980,13 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) return WithDb(options, jsonOptions, reader => { - var map = reader.GetRepoMap(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.MinEntrypointConfidence); + var compactLimit = GetCompactSectionLimit(options); + var mapLimit = options.Compact ? GetCompactSourceLimit(compactLimit) : options.Limit; + var map = reader.GetRepoMap(mapLimit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.MinEntrypointConfidence); WorkspaceMetadataEnricher.Enrich(map, options.DbPath, options.DbPathExplicit); if (options.ContextAfterExplicit) ApplyRepoMapDepth(map, options.ContextAfter); + var compactTruncation = options.Compact ? ApplyRepoMapCompactCaps(map, compactLimit, options) : null; // Return not-found only when a narrowing filter is active and produces zero files. // Unfiltered empty indexes return success (valid state for health probes). @@ -2983,7 +2997,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, compactTruncation); + Console.WriteLine(payload.ToJsonString(jsonOptions)); } else { @@ -2994,7 +3009,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { - var payload = BuildRepoMapJsonPayload(map, options, jsonOptions); + var payload = BuildRepoMapJsonPayload(map, options, jsonOptions, compactTruncation); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -3053,13 +3068,15 @@ private static void ApplyRepoMapDepth(RepoMapResult map, int depth) 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) + private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryCommandOptions options, JsonSerializerOptions jsonOptions, JsonObject? compactTruncation = null) { var payload = JsonSerializer.SerializeToNode(map, CliJsonSerializerContextFactory.Create(jsonOptions).RepoMapResult)!.AsObject(); if (options.MapSections == null) { if (options.ContextAfterExplicit) payload["depth"] = options.ContextAfter; + if (options.Compact && compactTruncation != null) + AddCompactJsonFields(payload, GetCompactSectionLimit(options), compactTruncation); return payload; } @@ -3100,9 +3117,183 @@ private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryComman payload["sections"] = new JsonArray(options.MapSections.Select(section => JsonValue.Create(section)).ToArray()); if (options.ContextAfterExplicit) payload["depth"] = options.ContextAfter; + if (options.Compact && compactTruncation != null) + AddCompactJsonFields(payload, GetCompactSectionLimit(options), compactTruncation); return payload; } + private static int GetCompactSectionLimit(QueryCommandOptions options) + => options.LimitExplicit ? options.Limit : DefaultCompactSectionLimit; + + private static int GetCompactSourceLimit(int compactLimit) + { + var sourceLimit = compactLimit + 1; + return NumericFlagUpperBounds.TryGetValue("--limit", out var maxLimit) + ? Math.Min(sourceLimit, maxLimit) + : sourceLimit; + } + + private static JsonObject ApplyRepoMapCompactCaps(RepoMapResult map, int sectionLimit, QueryCommandOptions options) + { + var sections = new JsonObject(); + if (MapSectionEnabled(options, "languages")) + TruncateCompactSection(map.Languages, sectionLimit, sections, "languages"); + if (MapSectionEnabled(options, "tree")) + TruncateCompactSection(map.Modules, sectionLimit, sections, "modules"); + if (MapSectionEnabled(options, "hotspots")) + { + TruncateCompactSection(map.TopFiles, sectionLimit, sections, "top_files"); + TruncateCompactSection(map.SymbolRichFiles, sectionLimit, sections, "symbol_rich_files"); + TruncateCompactSection(map.ReferenceRichFiles, sectionLimit, sections, "reference_rich_files"); + TruncateCompactSection(map.Entrypoints, sectionLimit, sections, "entrypoints"); + } + if (MapSectionEnabled(options, "metrics")) + TruncateCompactSection(map.LargestFiles, sectionLimit, sections, "largest_files"); + return BuildCompactTruncationMetadata(sectionLimit, sections); + } + + private static JsonObject ApplySymbolAnalysisCompactCaps(SymbolAnalysisResult analysis, int sectionLimit) + { + var sections = new JsonObject(); + TruncateCompactSection(analysis.Definitions, sectionLimit, sections, "definitions"); + TruncateCompactSection(analysis.NearbySymbols, sectionLimit, sections, "nearby_symbols"); + TruncateCompactSection(analysis.References, sectionLimit, sections, "references"); + TruncateCompactSection(analysis.Callers, sectionLimit, sections, "callers"); + TruncateCompactSection(analysis.Callees, sectionLimit, sections, "callees"); + return BuildCompactTruncationMetadata(sectionLimit, sections); + } + + private static JsonObject ApplyOutlineCompactCaps(OutlineResult outline, int sectionLimit) + { + var sections = new JsonObject(); + TruncateCompactSection(outline.Symbols, sectionLimit, sections, "symbols"); + return BuildCompactTruncationMetadata(sectionLimit, sections); + } + + private static JsonObject BuildCompactTruncationMetadata(int sectionLimit, JsonObject sections) + => new() + { + ["section_limit"] = sectionLimit, + ["sections"] = sections, + }; + + private static void AddCompactJsonFields(JsonObject payload, int compactLimit, JsonObject truncation) + { + payload["compact"] = true; + payload["compact_limit"] = compactLimit; + payload["truncation"] = truncation; + } + + private static void TruncateCompactSection(List items, int sectionLimit, JsonObject sections, string sectionName) + { + var sourceCount = items.Count; + if (sourceCount > sectionLimit) + items.RemoveRange(sectionLimit, sourceCount - sectionLimit); + + sections[sectionName] = new JsonObject + { + ["returned"] = items.Count, + ["source_count"] = sourceCount, + ["truncated"] = sourceCount > sectionLimit, + }; + } + + private static void ApplyInspectFieldSelection(JsonObject payload, QueryCommandOptions options, JsonSerializerOptions jsonOptions) + { + if (options.InspectFields == null) + return; + + payload["selected_fields"] = JsonSerializer.SerializeToNode(options.InspectFields, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + var keep = new HashSet(StringComparer.Ordinal) + { + "api_version", + "query", + "selected_fields", + }; + + foreach (var field in options.InspectFields) + AddInspectFieldProperties(keep, field); + + if (options.Compact) + { + keep.Add("compact"); + keep.Add("compact_limit"); + keep.Add("truncation"); + FilterInspectCompactTruncationSections(payload, options.InspectFields); + } + + foreach (var propertyName in payload.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) + payload.Remove(propertyName); + } + + private static void AddInspectFieldProperties(HashSet keep, string field) + { + switch (field) + { + case "file": + keep.Add("file"); + break; + case "workspace": + keep.Add("workspace_indexed_at"); + keep.Add("workspace_latest_modified"); + keep.Add("project_root"); + keep.Add("git_head"); + keep.Add("git_is_dirty"); + keep.Add("indexed_head_commit"); + keep.Add("worktree_head_changed"); + break; + case "graph": + keep.Add("graph_language"); + keep.Add("graph_supported"); + keep.Add("graph_support_reason"); + keep.Add("graph_degraded"); + keep.Add("unsupported_symbol_kind"); + keep.Add("graph_table_available"); + keep.Add("sql_graph_contract_ready"); + keep.Add("sql_graph_contract_degraded_reason"); + keep.Add("exact_zero_hint"); + keep.Add("exact_index_available"); + keep.Add("degraded"); + keep.Add("degraded_reason"); + break; + case "definitions": + keep.Add("definitions"); + break; + case "nearby_symbols": + keep.Add("nearby_symbols"); + break; + case "references": + keep.Add("references"); + break; + case "callers": + keep.Add("callers"); + break; + case "callees": + keep.Add("callees"); + break; + } + } + + private static void FilterInspectCompactTruncationSections(JsonObject payload, IReadOnlyCollection inspectFields) + { + if (!payload.TryGetPropertyValue("truncation", out var truncationNode) + || truncationNode is not JsonObject truncation + || !truncation.TryGetPropertyValue("sections", out var sectionsNode) + || sectionsNode is not JsonObject sections) + { + return; + } + + var keepSections = inspectFields + .Where(IsInspectListField) + .ToHashSet(StringComparer.Ordinal); + foreach (var sectionName in sections.Select(section => section.Key).Where(section => !keepSections.Contains(section)).ToList()) + sections.Remove(sectionName); + } + + private static bool IsInspectListField(string field) + => field is "definitions" or "nearby_symbols" or "references" or "callers" or "callees"; + public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var previewOptionError = ValidatePreviewOptions("inspect", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); @@ -3148,7 +3339,9 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return WithDb(options, jsonOptions, reader => { - var analysis = reader.AnalyzeSymbol(options.Query, options.Limit, options.Lang, options.IncludeBody, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth); + var compactLimit = GetCompactSectionLimit(options); + var inspectLimit = options.Compact ? GetCompactSourceLimit(compactLimit) : options.Limit; + var analysis = reader.AnalyzeSymbol(options.Query, inspectLimit, options.Lang, options.IncludeBody, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), DbReader.IsSqlLanguage(options.Lang) @@ -3173,8 +3366,12 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); if (options.Json) { + var compactTruncation = options.Compact ? ApplySymbolAnalysisCompactCaps(analysis, compactLimit) : null; var payload = JsonSerializer.SerializeToNode(analysis, CliJsonSerializerContextFactory.Create(jsonOptions).SymbolAnalysisResult)!.AsObject(); AddSqlGraphContractJsonFields(payload, sqlGraphSignal); + if (compactTruncation != null) + AddCompactJsonFields(payload, compactLimit, compactTruncation); + ApplyInspectFieldSelection(payload, options, jsonOptions); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -3267,7 +3464,18 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions if (options.Json) { - Console.WriteLine(JsonSerializer.Serialize(outline, CliJsonSerializerContextFactory.Create(jsonOptions).OutlineResult)); + if (options.Compact) + { + var compactLimit = GetCompactSectionLimit(options); + var compactTruncation = ApplyOutlineCompactCaps(outline, compactLimit); + var payload = JsonSerializer.SerializeToNode(outline, CliJsonSerializerContextFactory.Create(jsonOptions).OutlineResult)!.AsObject(); + AddCompactJsonFields(payload, compactLimit, compactTruncation); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } + else + { + Console.WriteLine(JsonSerializer.Serialize(outline, CliJsonSerializerContextFactory.Create(jsonOptions).OutlineResult)); + } } else { @@ -5599,6 +5807,8 @@ public static QueryCommandOptions ParseArgs( bool verbose = false; bool profile = false; int? slowQueryMs = null; + bool compact = false; + List? inspectFields = null; double minEntrypointConfidence = 0; string? statusExplainField = null; bool statusLogPath = false; @@ -5736,6 +5946,19 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--immutable": readOnly = true; break; + case "--pretty": + break; + case "--compact": + compact = true; + json = true; + outputFormat = OutputFormatJson; + break; + case "--body-only": + includeBody = true; + inspectFields = ["definitions"]; + json = true; + outputFormat = OutputFormatJson; + break; case "--workspace-db": if (TryReadStringOptionValue(args, ref i, "--workspace-db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var workspaceDbPath, out var workspaceDbError)) workspaceDbPaths.Add(workspaceDbPath!); @@ -5998,6 +6221,20 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(sectionsError!); break; + case "--fields": + if (TryReadStringOptionValue(args, ref i, "--fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var fieldsValue, out var fieldsError)) + { + WarnIfDuplicateSingleValueOption("--fields", fieldsValue!); + inspectFields = ParseInspectFields(fieldsValue!, AddParseError, out var includeBodyFromFields); + includeBody |= includeBodyFromFields; + json = true; + outputFormat = OutputFormatJson; + } + else + { + AddParseError(fieldsError!); + } + break; case "--fts": rawFts = true; break; @@ -6424,6 +6661,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) JsonOutputFormat = jsonOutputFormat, OutputFormat = outputFormat, Limit = limit, + LimitExplicit = limitExplicit, Lang = lang, Kind = kind, UnusedBucket = unusedBucket, @@ -6476,6 +6714,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Verbose = verbose, Profile = profile, SlowQueryMs = slowQueryMs, + Compact = compact, + InspectFields = inspectFields, MinEntrypointConfidence = minEntrypointConfidence, StatusExplainField = statusExplainField, StatusLogPath = statusLogPath, @@ -6524,6 +6764,80 @@ private static List ParseMapSections(string rawValue, Action add return sections.Distinct(StringComparer.Ordinal).ToList(); } + private static List? ParseInspectFields(string rawValue, Action addParseError, out bool includeBody) + { + includeBody = false; + var fields = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var all = false; + + if (!ValidateCsvBounds("--fields", rawValue, MaxInspectFieldsCsvLength, MaxInspectFieldsCsvEntries, addParseError)) + return fields; + + foreach (var rawField in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var field = rawField.ToLowerInvariant().Replace('-', '_'); + string canonical; + switch (field) + { + case "all": + all = true; + continue; + case "file": + canonical = "file"; + break; + case "metadata": + case "workspace": + canonical = "workspace"; + break; + case "graph": + case "trust": + canonical = "graph"; + break; + case "definition": + case "definitions": + case "defs": + canonical = "definitions"; + break; + case "body": + canonical = "definitions"; + includeBody = true; + break; + case "nearby": + case "nearby_symbols": + case "nearbysymbols": + canonical = "nearby_symbols"; + break; + case "reference": + case "references": + case "refs": + canonical = "references"; + break; + case "caller": + case "callers": + canonical = "callers"; + break; + case "callee": + case "callees": + canonical = "callees"; + break; + default: + addParseError($"Error: unsupported --fields value '{rawField}'. Use one or more of all, file, workspace, graph, definitions, body, nearby_symbols, references, callers, callees."); + continue; + } + + if (seen.Add(canonical)) + fields.Add(canonical); + } + + if (all && fields.Count > 0) + addParseError("Error: --fields all cannot be combined with specific field names."); + if (!all && fields.Count == 0) + addParseError("Error: --fields requires at least one field name."); + + return all ? null : fields; + } + private static bool ValidateCsvBounds( string optionName, string rawValue, @@ -9275,6 +9589,7 @@ public sealed class QueryCommandOptions public string JsonOutputFormat { get; init; } = "ndjson"; public string OutputFormat { get; init; } = "text"; public int Limit { get; init; } = 20; + public bool LimitExplicit { get; init; } public string? Lang { get; init; } public string? Kind { get; init; } public string? UnusedBucket { get; init; } @@ -9327,6 +9642,8 @@ public sealed class QueryCommandOptions public bool Verbose { get; init; } public bool Profile { get; init; } public int? SlowQueryMs { get; init; } + public bool Compact { get; init; } + public List? InspectFields { get; init; } public double MinEntrypointConfidence { get; init; } public string? StatusExplainField { get; init; } public bool StatusLogPath { get; init; } diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 9c9958e016..60692503bc 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -115,10 +115,10 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx references |--query |-- ", output); Assert.Contains("cdidx callers |--query |-- ", output); Assert.Contains("cdidx callees |--query |-- ", output); - Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); + Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]", output); Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); @@ -149,7 +149,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]", output); Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); Assert.Contains("--focus-line find/excerpt: focus a specific line", output); - Assert.Contains("cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]", output); + Assert.Contains("cdidx map [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); @@ -268,7 +268,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); + Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); @@ -887,7 +887,7 @@ public void PrintCompletions_ReportFlagSetsMatchAcrossShells() var expected = new SortedSet(StringComparer.Ordinal) { - "db", "json", "quiet", "silent", "no-progress", "output", "log-lines", "no-log", "include-args", + "db", "json", "pretty", "quiet", "silent", "no-progress", "output", "log-lines", "no-log", "include-args", }; Assert.Equal(expected, bashReport); Assert.Equal(expected, zshReport); diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index c22536e82f..a371f64e9b 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -60,6 +60,80 @@ public void ContainsJsonOutputFlag_AfterPassthrough_ReturnsFalse() Assert.False(ProgramRunner.ContainsJsonOutputFlag(["search", "--", "--json"])); } + [Fact] + public void RunLanguages_PrettyJson_IndentsOutput_Issue2996() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["languages", "--json", "--pretty"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + Assert.Contains(Environment.NewLine + " \"languages\": [", stdout); + using var document = JsonDocument.Parse(stdout); + Assert.True(document.RootElement.TryGetProperty("languages", out _)); + } + + [Fact] + public void RunSearch_FirstQueryLiteralMatchingPrettyFlag_IsNotConsumed_Issue2996() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_pretty_query_literal"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "README.md", + "markdown", + "--pretty appears here\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "--pretty", "--db", dbPath, "--path", "README.md", "--count", "--exact-substring"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("1" + Environment.NewLine, stdout); + Assert.Empty(stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_NdjsonWithPretty_KeepsOneJsonValuePerLine_Issue2996() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_pretty_ndjson"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "class App { void Needle() {} }\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, "--json", "--pretty"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + var lines = stdout.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + foreach (var line in lines) + { + Assert.False(line.StartsWith(" ", StringComparison.Ordinal)); + using var _ = JsonDocument.Parse(line); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_TestExtractor_PrintsIsolatedSymbols() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 30b4c9933c..530371a123 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -59,6 +59,161 @@ public void RunInspect_AllowsPathValueThatLooksLikePreviewOption() } } + [Fact] + public void RunInspect_CompactJson_CapsNearbySymbolsAndReportsTruncation_Issue3009() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_compact_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/SharedTarget.cs", + "csharp", + """ + public class SharedTarget + { + public void Run0() { } + public void Run1() { } + public void Run2() { } + public void Run3() { } + public void Run4() { } + public void Run5() { } + public void Run6() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["SharedTarget", "--db", dbPath, "--compact"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var nearbySymbols = json.GetProperty("nearby_symbols").EnumerateArray().ToList(); + var nearbySymbolsTruncation = json + .GetProperty("truncation") + .GetProperty("sections") + .GetProperty("nearby_symbols"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, nearbySymbols.Count); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, nearbySymbolsTruncation.GetProperty("returned").GetInt32()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit + 1, nearbySymbolsTruncation.GetProperty("source_count").GetInt32()); + Assert.True(nearbySymbolsTruncation.GetProperty("truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunInspect_ParseFields_ImplyJsonAndCanonicalizeAliases_Issue3056() + { + var options = QueryCommandRunner.ParseArgs( + ["--fields", "body,refs,nearby-symbols"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.True(options.Json); + Assert.True(options.IncludeBody); + Assert.Equal(["definitions", "references", "nearby_symbols"], options.InspectFields); + Assert.Null(options.ParseError); + } + + [Fact] + public void RunInspect_FieldsJson_EmitsOnlySelectedTopLevelGroups_Issue3056() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_fields_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public void Compute() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Target", "--db", dbPath, "--fields", "definitions,file"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var selectedFields = json.GetProperty("selected_fields").EnumerateArray().Select(item => item.GetString()).ToList(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(["definitions", "file"], selectedFields); + Assert.True(json.TryGetProperty("api_version", out _)); + Assert.True(json.TryGetProperty("query", out _)); + Assert.True(json.TryGetProperty("definitions", out _)); + Assert.True(json.TryGetProperty("file", out _)); + Assert.False(json.TryGetProperty("nearby_symbols", out _)); + Assert.False(json.TryGetProperty("references", out _)); + Assert.False(json.TryGetProperty("callers", out _)); + Assert.False(json.TryGetProperty("callees", out _)); + Assert.False(json.TryGetProperty("graph_supported", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunInspect_BodyOnlyJson_EmitsDefinitionBodiesOnly_Issue3056() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_only_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public int Compute() + { + return 42; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--body-only"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var selectedFields = json.GetProperty("selected_fields").EnumerateArray().Select(item => item.GetString()).ToList(); + var definition = json.GetProperty("definitions").EnumerateArray().Single(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(["definitions"], selectedFields); + Assert.Contains("return 42;", definition.GetProperty("body_content").GetString(), StringComparison.Ordinal); + Assert.False(json.TryGetProperty("file", out _)); + Assert.False(json.TryGetProperty("references", out _)); + Assert.False(json.TryGetProperty("callers", out _)); + Assert.False(json.TryGetProperty("callees", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunOutline_IndentsByContainerDepth() { @@ -117,6 +272,107 @@ public void Method() { } } } + [Fact] + public void RunOutline_CompactJson_CapsSymbolsAndReportsTruncation_Issue3009() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_compact_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/many.cs", + "csharp", + """ + public class Many + { + public void M0() { } + public void M1() { } + public void M2() { } + public void M3() { } + public void M4() { } + public void M5() { } + public void M6() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunOutline( + ["src/many.cs", "--db", dbPath, "--compact"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var symbols = json.GetProperty("symbols").EnumerateArray().ToList(); + var symbolTruncation = json + .GetProperty("truncation") + .GetProperty("sections") + .GetProperty("symbols"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, symbols.Count); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, symbolTruncation.GetProperty("returned").GetInt32()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit + 3, symbolTruncation.GetProperty("source_count").GetInt32()); + Assert.True(symbolTruncation.GetProperty("truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunOutline_CompactJson_UsesExplicitLimit_Issue3009() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_outline_compact_limit_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/many.cs", + "csharp", + """ + public class Many + { + public void M0() { } + public void M1() { } + public void M2() { } + public void M3() { } + public void M4() { } + public void M5() { } + public void M6() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunOutline( + ["src/many.cs", "--db", dbPath, "--compact", "--limit", "2"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var symbols = json.GetProperty("symbols").EnumerateArray().ToList(); + var symbolTruncation = json + .GetProperty("truncation") + .GetProperty("sections") + .GetProperty("symbols"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(2, json.GetProperty("compact_limit").GetInt32()); + Assert.Equal(2, symbols.Count); + Assert.Equal(2, symbolTruncation.GetProperty("returned").GetInt32()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit + 3, symbolTruncation.GetProperty("source_count").GetInt32()); + Assert.True(symbolTruncation.GetProperty("truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunOutline_UsesNestedSymbolDepthInHumanOutput() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs index 017063cda7..e274b1ceea 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs @@ -24,6 +24,65 @@ public void RunMap_ParseSectionsAndDepth_StoresSelectors() Assert.Null(options.ParseError); } + [Fact] + public void RunMap_ParseCompact_ImpliesJsonAndPreservesExplicitLimit_Issue3009() + { + var options = QueryCommandRunner.ParseArgs( + ["--compact", "--limit", "3"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.True(options.Json); + Assert.True(options.Compact); + Assert.True(options.LimitExplicit); + Assert.Equal(3, options.Limit); + Assert.Null(options.ParseError); + } + + [Fact] + public void RunMap_CompactJson_CapsSectionsAndReportsTruncation_Issue3009() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_map_compact"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < QueryCommandRunner.DefaultCompactSectionLimit + 2; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/module{i}/App{i}.cs", + "csharp", + $"namespace Module{i}; public class App{i} {{ public void Run() {{ }} }}\n"); + } + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunMap( + ["--db", dbPath, "--compact"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var topFiles = json.GetProperty("top_files").EnumerateArray().ToList(); + var topFilesTruncation = json + .GetProperty("truncation") + .GetProperty("sections") + .GetProperty("top_files"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, json.GetProperty("compact_limit").GetInt32()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, topFiles.Count); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, topFilesTruncation.GetProperty("returned").GetInt32()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit + 1, topFilesTruncation.GetProperty("source_count").GetInt32()); + Assert.True(topFilesTruncation.GetProperty("truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunMap_WithJsonIncludesWorkspaceMetadataForProjectDb() {