From 7236e8d12d53563f4d0a076f866ad3049acbb317 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 13:14:34 +0900 Subject: [PATCH 1/3] Add nested inspect field projections (#5098) --- DEVELOPER_GUIDE.md | 23 ++ TESTING_GUIDE.md | 2 + USER_GUIDE.md | 34 ++- changelog.d/unreleased/5098.added.md | 20 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.Help.cs | 9 + src/CodeIndex/Cli/ConsoleUi.cs | 4 +- src/CodeIndex/Cli/ProjectionFieldRegistry.cs | 134 ++++++++++- .../Cli/QueryCommandRunner.ArgParsing.cs | 112 ++++------ ...eryCommandRunner.ArgumentParser.Filters.cs | 6 +- .../Cli/QueryCommandRunner.ArgumentParser.cs | 2 + .../Cli/QueryCommandRunner.Inspect.cs | 117 +++++++++- src/CodeIndex/Cli/QueryCommandRunner.cs | 1 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 9 +- .../QueryCommandRunnerInspectTests.cs | 208 +++++++++++++++++- 15 files changed, 590 insertions(+), 93 deletions(-) create mode 100644 changelog.d/unreleased/5098.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 995173d47..afcdd3b67 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2190,6 +2190,18 @@ case-sensitive; unknown values use the versioned `E010_USAGE_ERROR` command error when JSON is requested, and discovery runs before query or database access. +`inspect` keeps a dedicated typed schema in the same registry because its +established JSON bundle is not a shared bounded-response envelope. It accepts +top-level groups and exactly one `collection.field` level for definitions, +nearby symbols, references, callers, and callees. Inspect selectors normalize +case and hyphens, resolve aliases before first-occurrence deduplication, and +preserve canonical request order. A selected parent dominates its children and +keeps complete rows; otherwise the row projector emits only selected leaves. +Projection runs before final serialization and byte budgeting while preserving +root metadata, section totals/cursors/truncation, partial-family metadata, and +definition body paging/recovery fields. The queryless `inspect --fields list` +catalog and unknown-field errors are generated from that same schema. + | Output mode | Contract | |---|---| | Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `outline`, `suggestions`) default to **human-readable output**. | @@ -6113,6 +6125,17 @@ help はすべてこのレジストリを参照します。field 名は大文字 値で JSON が要求されている場合は versioned `E010_USAGE_ERROR` command error を 返します。発見処理は query や database access より先に実行します。 +`inspect` は従来の JSON bundle を shared bounded-response envelope に変更しないため、 +同じレジストリ内に専用の typed schema を持ちます。top-level group に加え、definitions、 +nearby symbols、references、callers、callees では 1 階層だけの `collection.field` を +受け付けます。inspect selector は大小文字と hyphen を正規化し、alias 解決後に最初の出現を +残して重複を除き、canonical な指定順を維持します。parent が選択されていれば child より +優先して完全な row を保持し、それ以外は選択した leaf だけを row projector が出力します。 +projection は最終 serialization と byte budget 適用より前に行い、root metadata、section の +total / cursor / truncation、partial-family metadata、definition body の paging / recovery field を +維持します。query 不要の `inspect --fields list` catalog と未知 field error も同じ schema から +生成します。 + | output mode | 契約 | |---|---| | human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`outline`、`suggestions`)は既定で**人間向け出力**です。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cb16acb6d..4e609aef5 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5098 inspect projection coverage belongs in `QueryCommandRunnerInspectTests.cs`. Keep one graph-ready partial-type fixture covering canonical and alias selectors, multiple leaves across collections, parent-over-child precedence, stable row order, empty arrays, compact truncation, byte bounds, body paging/recovery, partial-family metadata, queryless catalog discovery, and typed unknown parent/leaf errors. `ConsoleUiTests.cs` separately pins the `` usage, nested-field help text, and examples. - Path-containment coverage for #5091 pairs platform-independent `PathCasingTests` seams for mixed parent-namespace case policies and directory identities with `FileIndexerTests` integration through an internal file symlink. Keep both checks when changing internal-symlink policy: a case-insensitive project mount must not make a distinct case-only sibling in its case-sensitive parent namespace appear internal, while genuine same-namespace aliases remain accepted. - `WorkspaceCheckTruncationIssue5055Tests` owns the bounded `workspace_check` list contract. Keep zero, below-limit, exact-limit, and over-limit coverage for all six categories, plus the 1260/20/1240 example and one shared filesystem fixture for raw JSON, list-only projection, compact output, byte-budget trimming, deterministic order, and human sample labeling. - Recipe issue-draft summary coverage in `QueryCommandRunnerSearchTests` keeps the dedicated compact DTO separate from full draft bodies, asserts returned/total/omitted row cardinality (including `count == drafts.length` and positive queries hidden by `--total-limit`), preserves pre-limit evidence-path counts with authority/lower-bound metadata, measures the complete UTF-8 document with multibyte paths and its final newline, verifies whole-row truncation and typed `E028` audit identity, and preserves the full-output contract. @@ -1107,6 +1108,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5098 の inspect projection coverage は `QueryCommandRunnerInspectTests.cs` が担当します。graph-ready な partial type fixture 1つで canonical / alias selector、collection をまたぐ複数 leaf、parent の child に対する優先、安定した row 順、空配列、compact truncation、byte 上限、body paging / recovery、partial-family metadata、query 不要の catalog discovery、未知 parent / leaf の型付き error を維持してください。`ConsoleUiTests.cs` では `` usage、nested-field help text、例を別途固定します。 - #5091 の path containment coverage は、親 directory namespace の case policy と directory identity が混在する状況を OS 非依存で再現する `PathCasingTests` の seam と、内部 file symlink を通す `FileIndexerTests` の integration を対にします。internal-symlink policy を変更するときは両方を維持し、case-insensitive な project mount の policy で、case-sensitive な親 namespace にある別 identity の case-only sibling を内部 path と誤認しないこと、および同一 namespace の正当な alias は引き続き受理されることを検証してください。 - `WorkspaceCheckTruncationIssue5055Tests` は上限付き `workspace_check` 一覧の契約を担当します。6カテゴリすべての0件・上限未満・上限一致・上限超過、1260/20/1240 の例、および raw JSON、一覧単独 projection、compact 出力、byte budget による短縮、決定的順序、human 向け sample 表示を1つの共有 filesystem fixture で検証する coverage を維持してください。 - `QueryCommandRunnerSearchTests` の recipe issue-draft summary coverage は、専用 compact DTO を完全版 draft body から分離し、返却 / total / omitted row の cardinality(`count == drafts.length` と `--total-limit` に隠れた positive query を含む)、limit 適用前の evidence-path count と authority / lower-bound metadata の維持、multibyte path と末尾改行を含む UTF-8 document 全体の計測、完全な row 単位の truncation、型付き `E028` の audit identity、完全版 output contract の維持を検証します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 09a97a989..4e0870b5d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -390,7 +390,7 @@ When a bounded `find --all` scan exits partially, its terminal record includes `next_cursor`; replaying it resumes after the last scanned line. The bounded-response commands `search`, `definition`, `find`, `status`, `hotspots`, `references`, `callers`, `callees`, `symbols`, `files`, -`languages`, `impact`, and `map` validate `--fields` values case-sensitively +`languages`, `impact`, `map`, and `inspect` validate `--fields` values against one command-specific registry. Unknown names return a typed `E010_USAGE_ERROR` instead of successful empty objects. Run `cdidx --fields list` before a query to obtain the machine-readable @@ -415,9 +415,18 @@ same section envelopes and accepts their cursors. In path/line mode, `--path` locates the definition but does not restrict inbound references or callers to that file. Inspect graph cursors are accepted only by `inspect`; passing one to another command is a usage error. -For narrower `inspect` evidence, `--fields ` implies JSON and selects +For narrower `inspect` evidence, `--fields ` implies JSON and selects top-level groups such as `definitions`, `file`, `graph`, `references`, -`callers`, and `callees`; `--outline-only` is shorthand for +`callers`, and `callees`. Collection selectors accept one nested level, for +example `definitions.name`, `definitions.path`, `definitions.line`, +`references.path`, or `callers.path`. Selecting a parent keeps the full rows; +when a parent and child are both requested, the parent wins. Child rows follow +the canonical request order, aliases and duplicates are normalized +deterministically, empty arrays remain arrays, and response counts, truncation, +cursor, body recovery, and partial-family metadata remain available. Unknown +parents or leaves return a typed usage error with the field catalog. Run +`cdidx inspect --fields list` without a query or database to inspect that +catalog. `--outline-only` is shorthand for `--fields file,definitions,nearby_symbols`, and `--body-only` is shorthand for `--body --fields definitions`. When a definition body is longer than the returned slice, `body_content_next_start_line` points to the next source line to pass with @@ -457,6 +466,8 @@ cdidx inspect Compute --body-only # definitions with body_content only cdidx inspect Compute --body --body-start 40 --body-lines 40 cdidx inspect Compute --line 42 --context 2 --json cdidx inspect Compute --json --limit 1 --cursor '' +cdidx inspect Compute --json --fields definitions.name,definitions.path,references.line +cdidx inspect --fields list ``` ## Editor and index portability @@ -2243,7 +2254,7 @@ same source location. | `--summary-only` | `map`, `recipes`, `audit`, `deps`, `hotspots`, and supported `search` JSON contexts | Emit aggregate/context JSON while omitting heavy result arrays where supported. For `deps`, use `--json` or `--format json-graph`; for `hotspots`, use `--json`. Machine-readable `deps` output emits `Progress:` diagnostics only with `--verbose`; other large graph queries emit them at `--limit 80+` or with `--verbose`. | | `--sort ` | `symbols`, `outline` | For `outline`, sort one file's symbols by `source`, `kind`, `references`, `size` / `span`, `complexity`, `path`, or `name` before `--limit` / cursor paging. | | `--outline-fields ` | `outline` | Project outline JSON symbol fields such as `name`, `line`, `kind`, `signature`, `container`, `range`, `body`, `reference_count`, `size_lines`, `complexity_score`, or `sort_mode`; pass `all` for the full symbol payload with paging metadata. | -| `--fields ` | `inspect` | Select top-level inspect JSON groups: `file`, `workspace`, `graph`, `definitions`, `body`, `source_excerpt`, `nearby_symbols`, `references`, `callers`, `callees`, or `all`. `body` includes definition bodies and maps to `definitions`. | +| `--fields ` | `inspect` | Select top-level inspect JSON groups or one-level collection leaves such as `definitions.name`, `definitions.path`, `references.line`, and `callers.path`. A parent keeps full rows and wins over its children; aliases, duplicates, and output order are normalized deterministically. `body` includes definition bodies and maps to `definitions`. Use `list` for the queryless typed catalog. | | `--outline-only` | `inspect` | Shorthand for `--fields file,definitions,nearby_symbols`, useful for outline-first review of large classes/types before requesting body or graph evidence. | | `--body-only` | `inspect` | Shorthand for `--body --fields definitions`, useful when large audits need implementation text without graph context. | | `--body-start ` | `inspect` | Start the returned definition body slice at a 1-based source line inside the symbol body. Pair with `body_content_next_start_line` from JSON to page a long body. | @@ -4000,9 +4011,16 @@ path/line mode の `--path` は定義の位置を特定しますが、inbound re そのファイルだけに制限しません。MCP `analyze_symbol` も同じ section envelope を公開し、 その cursor を受け付けます。inspect graph cursor は `inspect` だけが受理し、別 command に 渡すと usage error になります。 -`inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 +`inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 `definitions`、`file`、`graph`、`references`、`callers`、`callees` などの -top-level group を選択します。`--outline-only` は +top-level group を選択します。collection selector は 1 階層の nested field に対応し、 +たとえば `definitions.name`、`definitions.path`、`definitions.line`、 +`references.path`、`callers.path` を指定できます。parent を選ぶと row 全体を保持し、 +parent と child を同時指定した場合は parent が優先されます。child row は canonical な +指定順を保ち、alias と重複は決定的に正規化されます。空配列は配列のまま保持され、count、 +truncation、cursor、body recovery、partial-family metadata も維持されます。未知の parent / leaf +は field catalog 付きの型付き usage error になります。query や DB なしで +`cdidx inspect --fields list` を実行すると catalog を確認できます。`--outline-only` は `--fields file,definitions,nearby_symbols` の shorthand で、`--body-only` は `--body --fields definitions` の shorthand です。definition body が返却 slice より長い場合は `body_content_next_start_line` が次に `--body-start` へ渡す source line を示します。 @@ -4040,6 +4058,8 @@ cdidx inspect Compute --body-only # body_content 付き definitions の cdidx inspect Compute --body --body-start 40 --body-lines 40 cdidx inspect Compute --line 42 --context 2 --json cdidx inspect Compute --json --limit 1 --cursor '' +cdidx inspect Compute --json --fields definitions.name,definitions.path,references.line +cdidx inspect --fields list ``` ## Editor / index portability @@ -5747,7 +5767,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--summary-only` | `map`、`recipes`、`audit`、`deps`、`hotspots`、および対応する `search` JSON 文脈 | 対応コマンドで重い結果配列を省き、集計と文脈中心の JSON を返す。`deps` では `--json` または `--format json-graph`、`hotspots` では `--json` と組み合わせる。machine-readable な `deps` 出力は `--verbose` 指定時だけ stderr へ `Progress:` 診断を出し、それ以外の大きい graph query は `--limit 80` 以上または `--verbose` 指定時に出す。 | | `--sort ` | `symbols`、`outline` | `outline` では 1ファイル内のシンボルを `source`、`kind`、`references`、`size` / `span`、`complexity`、`path`、`name` で並べ替えてから `--limit` / カーソルページングを適用する。 | | `--outline-fields ` | `outline` | outline JSON のシンボルフィールドを投影する。`name`、`line`、`kind`、`signature`、`container`、`range`、`body`、`reference_count`、`size_lines`、`complexity_score`、`sort_mode` などを指定でき、`all` を渡すとシンボルペイロード全体とページングメタデータを返す。 | -| `--fields ` | `inspect` | inspect JSON の top-level group を選択。`file`、`workspace`、`graph`、`definitions`、`body`、`source_excerpt`、`nearby_symbols`、`references`、`callers`、`callees`、`all` を指定できる。`body` は definition body を含め、`definitions` に対応する。 | +| `--fields ` | `inspect` | inspect JSON の top-level group または `definitions.name`、`definitions.path`、`references.line`、`callers.path` など 1 階層の collection leaf を選択する。parent は row 全体を保持して child より優先され、alias、重複、出力順は決定的に正規化される。`body` は definition body を含め、`definitions` に対応する。query 不要の型付き catalog は `list` で取得できる。 | | `--outline-only` | `inspect` | `--fields file,definitions,nearby_symbols` の shorthand。大きな class / type を body や graph evidence なしでアウトライン優先で確認したい場合に使う。 | | `--body-only` | `inspect` | `--body --fields definitions` の shorthand。大規模 audit で graph context なしに実装本文だけが必要な場合に使う。 | | `--body-start ` | `inspect` | symbol body 内の 1-based source line から definition body slice を返す。長い body の page 送りでは JSON の `body_content_next_start_line` を次の値として渡す。 | diff --git a/changelog.d/unreleased/5098.added.md b/changelog.d/unreleased/5098.added.md new file mode 100644 index 000000000..32153a166 --- /dev/null +++ b/changelog.d/unreleased/5098.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 5098 +affected: + - src/CodeIndex/Cli/ProjectionFieldRegistry.cs + - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Inspect JSON supports nested collection field projections (#5098)** — `cdidx inspect --fields` can now select one-level leaves such as `definitions.name`, `references.path`, and `callers.line`, while preserving response metadata, compact and byte-bounded behavior, body recovery fields, deterministic parent/alias/order semantics, typed unknown-field errors, and a queryless `--fields list` catalog. + +## 日本語 + +- **inspect JSON で collection の nested field projection に対応しました (#5098)** — `cdidx inspect --fields` で `definitions.name`、`references.path`、`callers.line` など 1 階層の leaf を選択できるようになりました。response metadata、compact / byte 上限制御、body recovery field、決定的な parent / alias / 順序の規則、未知 field の型付き error、query 不要の `--fields list` catalog も維持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 95640c01e..35aee24b5 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -547,7 +547,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--body-start", ValuePlaceholder = "", Description = "Inspect: start definition body slice at this 1-based source line", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-lines", ValuePlaceholder = "", Description = "Inspect: return at most this many definition body lines", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-line-count", ValuePlaceholder = "", Description = "Inspect: alias for --body-lines", PrimaryCommands = Set(InspectFieldCommands) }, - new() { Name = "--fields", ValuePlaceholder = "", Description = "Project bounded-response row fields; inspect selects top-level evidence groups; nested collections accept collection.field", PrimaryCommands = Set(InspectFieldCommands.Concat(BoundedProjectionCommands).ToArray()) }, + new() { Name = "--fields", ValuePlaceholder = "", Description = "Project bounded-response fields; inspect accepts top-level groups or collection.field and `list` prints its typed catalog", PrimaryCommands = Set(InspectFieldCommands.Concat(BoundedProjectionCommands).ToArray()) }, new() { Name = "--body-only", Description = "Inspect: body-focused JSON shorthand for --body --fields definitions", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--outline-only", Description = "Inspect: outline-first JSON shorthand for --fields file,definitions,nearby_symbols", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand; search mode is incompatible with --fts", PrimaryCommands = Set(ExactCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 9aa2b507a..8c9feda87 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -253,6 +253,9 @@ private static void PrintExamples() Console.WriteLine(" cdidx excerpt src/app.cs --start 10 --end 20 Reconstruct a file excerpt"); Console.WriteLine(" cdidx map --path src/ --exclude-tests Show a repo map for source code"); Console.WriteLine(" cdidx inspect Run --body --exclude-tests Inspect one symbol with bundled context"); + Console.WriteLine(" cdidx inspect Run --json --fields definitions.name,definitions.path,references.line"); + Console.WriteLine(" Project selected inspect collection leaves"); + Console.WriteLine(" cdidx inspect --fields list Print the typed inspect field catalog"); Console.WriteLine(" cdidx outline src/app.cs --json Symbol outline of a single file"); Console.WriteLine(" cdidx deps --path src/ --exclude-tests Show file-level dependency edges"); Console.WriteLine(" cdidx deps --reverse --path src/app.cs Show what depends on a file"); @@ -399,13 +402,19 @@ public static bool PrintCommandUsage(string command) foreach (var flag in helpFlags) { var names = flag.ShortName is null ? flag.Name : $"{flag.Name}, {flag.ShortName}"; + var inspectProjectionFields = string.Equals(flag.Name, "--fields", StringComparison.Ordinal) + && string.Equals(schemaCommand, "inspect", StringComparison.Ordinal); var projectionFields = string.Equals(flag.Name, "--fields", StringComparison.Ordinal) && ProjectionFieldRegistry.SupportsCommand(schemaCommand); var valuePlaceholder = projectionFields ? ProjectionFieldRegistry.GetHelpValuePlaceholder(schemaCommand) + : inspectProjectionFields + ? "" : flag.GetValuePlaceholder(schemaCommand); var description = projectionFields ? ProjectionFieldRegistry.GetHelpDescription(schemaCommand) + : inspectProjectionFields + ? ProjectionFieldRegistry.GetInspectHelpDescription() : flag.GetDescription(schemaCommand); var token = valuePlaceholder is null ? names : $"{names} {valuePlaceholder}"; Console.WriteLine($" {token}"); diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 664128ed8..c78d46f37 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -97,8 +97,8 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--clamp] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), - ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), + ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), + ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]"), ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config [--redact-paths|--show-paths]] [--check-updates]"), ("workspace", "cdidx workspace [name-or-relative-path] [--json] [--check]"), diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs index f7b7d0101..7f391de71 100644 --- a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -27,6 +27,12 @@ internal static class ProjectionFieldRegistry "remediation", ]; + // Inspect keeps its existing single-bundle JSON contract instead of entering the + // shared row-envelope path, but its selectors still need one typed source of truth. + // inspect は既存の単一 bundle JSON 契約を維持するため shared row envelope には + // 登録しないが、selector は同じ型由来レジストリで一元管理する。Issue #5098. + private static readonly ProjectionCommandFieldSchema InspectSchema = CreateInspectSchema(); + private static readonly IReadOnlyDictionary Schemas = new Dictionary(StringComparer.Ordinal) { @@ -104,6 +110,9 @@ internal static string GetHelpValuePlaceholder(string command) internal static string GetHelpDescription(string command) => $"Project validated {command} response fields (case-sensitive); use --fields list for the machine-readable catalog."; + internal static string GetInspectHelpDescription() + => "Project inspect JSON groups or collection.field leaves; parent groups keep full rows, and --fields list prints the machine-readable catalog."; + internal static bool TryValidate( string command, IReadOnlyList? requestedFields, @@ -144,6 +153,66 @@ internal static bool TryValidate( internal static JsonObject CreateDiscoveryDocument(string command) { var schema = Schemas[command]; + return CreateDiscoveryDocument(schema, caseSensitive: true); + } + + internal static bool IsInspectDiscoveryRequest(IReadOnlyList? fields) + => fields is { Count: 1 } + && string.Equals(fields[0], DiscoveryValue, StringComparison.Ordinal); + + internal static bool TryResolveInspectSelector( + string rawSelector, + out string canonicalSelector, + out bool includeBody, + out IReadOnlyList? expansion, + out ProjectionFieldValidationError? error) + { + canonicalSelector = string.Empty; + includeBody = false; + expansion = null; + error = null; + + var normalized = rawSelector.Trim().ToLowerInvariant().Replace('-', '_'); + if (string.Equals(normalized, DiscoveryValue, StringComparison.Ordinal)) + { + canonicalSelector = DiscoveryValue; + return true; + } + var definition = InspectSchema.Fields.FirstOrDefault(field => + string.Equals(field.Name, normalized, StringComparison.Ordinal)); + if (definition is null) + { + var nearby = ConsoleUi.FindClosestMatches(normalized, InspectSchema.ValidFieldNames); + var candidateHint = nearby.Count > 0 + ? $" Nearby valid fields: {string.Join(", ", nearby)}." + : $" Valid fields include: {string.Join(", ", InspectSchema.ValidFieldNames.Take(8))}."; + error = new ProjectionFieldValidationError( + $"Unknown --fields value '{ConsoleUi.FormatBoundedValue(rawSelector)}' for command 'inspect'.", + $"{candidateHint.TrimStart()} Run `cdidx inspect --fields {DiscoveryValue}` for the complete catalog."); + return false; + } + + canonicalSelector = definition.AliasFor ?? definition.Name; + expansion = definition.ExpandsTo; + includeBody = string.Equals(normalized, "body", StringComparison.Ordinal) + || canonicalSelector.StartsWith("definitions.body_", StringComparison.Ordinal); + return true; + } + + internal static JsonObject CreateInspectDiscoveryDocument() + { + var document = CreateDiscoveryDocument(InspectSchema, caseSensitive: false); + document["normalization"] = "lowercase_and_hyphen_to_underscore"; + document["parent_child_behavior"] = "parent_selector_returns_full_rows"; + document["duplicate_behavior"] = "first_canonical_selector_wins"; + document["ordering"] = "canonical_request_order"; + return document; + } + + private static JsonObject CreateDiscoveryDocument( + ProjectionCommandFieldSchema schema, + bool caseSensitive) + { var fields = new JsonArray(); foreach (var definition in schema.Fields) { @@ -157,14 +226,19 @@ internal static JsonObject CreateDiscoveryDocument(string command) item["alias_for"] = definition.AliasFor; if (definition.Collection is not null) item["collection"] = definition.Collection; + if (definition.ExpandsTo is not null) + { + item["expands_to"] = new JsonArray( + definition.ExpandsTo.Select(field => (JsonNode?)field).ToArray()); + } fields.Add(item); } return new JsonObject { ["api_version"] = "1", - ["command"] = command, - ["case_sensitive"] = true, + ["command"] = schema.Command, + ["case_sensitive"] = caseSensitive, ["discovery_value"] = DiscoveryValue, ["valid_fields"] = new JsonArray( schema.ValidFieldNames.Select(field => (JsonNode?)field).ToArray()), @@ -172,6 +246,53 @@ internal static JsonObject CreateDiscoveryDocument(string command) }; } + private static ProjectionCommandFieldSchema CreateInspectSchema() + { + var definitionFields = GetJsonFieldNames() + .Where(field => !string.Equals(field, "content", StringComparison.Ordinal)) + .Concat(["content_omitted", "content_omitted_reason"]) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var nearbyFields = GetJsonFieldNames().ToArray(); + var referenceFields = GetJsonFieldNames().ToArray(); + var callerFields = GetJsonFieldNames().ToArray(); + var calleeFields = GetJsonFieldNames().ToArray(); + + return Create( + "inspect", + [], + builder => builder + .Fields("file", "workspace", "graph", "source_excerpt", "candidates") + .Collection("definitions", definitionFields, pathAlias: true) + .Collection("nearby_symbols", nearbyFields, pathAlias: true) + .Collection("references", referenceFields, pathAlias: true) + .Collection("callers", callerFields, pathAlias: true) + .Collection("callees", calleeFields, pathAlias: true) + .Alias("metadata", "workspace") + .Alias("trust", "graph") + .Alias("definition", "definitions") + .Alias("defs", "definitions") + .Alias("body", "definitions") + .Alias("source", "source_excerpt") + .Alias("excerpt", "source_excerpt") + .Alias("nearby", "nearby_symbols") + .Alias("nearbysymbols", "nearby_symbols") + .Alias("reference", "references") + .Alias("refs", "references") + .Alias("caller", "callers") + .Alias("callee", "callees") + .Alias("candidate", "candidates") + .Alias("candidate_bundles", "candidates") + .Alias("definitions.body", "definitions.body_content") + .Alias("callers.line", "callers.first_line") + .Alias("callers.column", "callers.first_column") + .Alias("callees.line", "callees.first_line") + .Alias("callees.column", "callees.first_column") + .Expansion("outline", ["file", "definitions", "nearby_symbols"]) + .Expansion("outline_only", ["file", "definitions", "nearby_symbols"]) + .Expansion("outlineonly", ["file", "definitions", "nearby_symbols"])); + } + private static ProjectionCommandFieldSchema CreateSearchSchema() => Create( "search", @@ -450,6 +571,12 @@ internal ProjectionFieldSchemaBuilder Collection( return this; } + internal ProjectionFieldSchemaBuilder Expansion(string name, IReadOnlyList fields) + { + Add(new ProjectionFieldDefinition(name, "shorthand", null, false, null, fields)); + return this; + } + internal ProjectionCommandFieldSchema Build(string command, IReadOnlyList compactFields) { var missingCompactField = compactFields.FirstOrDefault(field => !_names.Contains(field)); @@ -481,7 +608,8 @@ internal sealed record ProjectionFieldDefinition( string Kind, string? AliasFor, bool Deprecated, - string? Collection); + string? Collection, + IReadOnlyList? ExpandsTo = null); internal sealed record ProjectionCommandFieldSchema( string Command, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index cf11fbae7..f52907ca4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -178,9 +178,14 @@ 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) + private static List? ParseInspectFields( + string rawValue, + Action addParseError, + out bool includeBody, + out ProjectionFieldValidationError? validationError) { includeBody = false; + validationError = null; var fields = new List(); var seen = new HashSet(StringComparer.Ordinal); var all = false; @@ -191,74 +196,33 @@ private static List ParseMapSections(string rawValue, Action add foreach (var rawField in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var field = rawField.ToLowerInvariant().Replace('-', '_'); - string canonical; - switch (field) + if (!ProjectionFieldRegistry.TryResolveInspectSelector( + rawField, + out var canonical, + out var selectorIncludesBody, + out var expansion, + out var selectorError)) { - 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 "source": - case "source_excerpt": - case "excerpt": - canonical = "source_excerpt"; - break; - case "nearby": - case "nearby_symbols": - case "nearbysymbols": - canonical = "nearby_symbols"; - break; - case "outline": - case "outline_only": - case "outlineonly": - foreach (var outlineField in new[] { "file", "definitions", "nearby_symbols" }) - { - if (seen.Add(outlineField)) - fields.Add(outlineField); - } - continue; - case "reference": - case "references": - case "refs": - canonical = "references"; - break; - case "caller": - case "callers": - canonical = "callers"; - break; - case "callee": - case "callees": - canonical = "callees"; - break; - case "candidate": - case "candidates": - case "candidate_bundles": - canonical = "candidates"; - break; - default: - invalidField = true; - addParseError($"Error: unsupported --fields value '{ConsoleUi.FormatBoundedValue(rawField)}'. Use one or more of all, file, workspace, graph, definitions, body, source_excerpt, nearby_symbols, outline, references, callers, callees, candidates."); - continue; + invalidField = true; + validationError ??= selectorError; + addParseError($"Error: {selectorError!.Message}"); + continue; + } + + includeBody |= selectorIncludesBody; + if (expansion is not null) + { + foreach (var expandedField in expansion) + { + if (seen.Add(expandedField)) + fields.Add(expandedField); + } + continue; + } + if (string.Equals(canonical, "all", StringComparison.Ordinal)) + { + all = true; + continue; } if (seen.Add(canonical)) @@ -266,7 +230,19 @@ private static List ParseMapSections(string rawValue, Action add } if (all && fields.Count > 0) + { + validationError ??= new ProjectionFieldValidationError( + "The --fields selector 'all' cannot be combined with specific field names for command 'inspect'.", + "Use `--fields all` by itself, or remove `all` and list only the required groups or collection fields."); addParseError("Error: --fields all cannot be combined with specific field names."); + } + if (fields.Contains("list", StringComparer.Ordinal) && fields.Count > 1) + { + validationError ??= new ProjectionFieldValidationError( + "The --fields discovery value 'list' must be used by itself for command 'inspect'.", + "Run `cdidx inspect --fields list` without other field names."); + addParseError("Error: --fields list cannot be combined with specific field names."); + } if (!all && fields.Count == 0 && !invalidField) addParseError("Error: --fields requires at least one field name."); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs index 82e722cfb..0e88bf1c3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs @@ -123,7 +123,11 @@ private bool TryParseFilterOption(string normalizedArg, string currentArg, strin 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); + inspectFields = ParseInspectFields( + fieldsValue!, + AddParseError, + out var includeBodyFromFields, + out inspectFieldValidationError); includeBody |= includeBodyFromFields; json = true; outputFormat = OutputFormatJson; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index c570e30f1..f1d9579f9 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -124,6 +124,7 @@ private sealed partial class QueryArgumentParser private int? slowQueryMs; private bool compact; private List? inspectFields; + private ProjectionFieldValidationError? inspectFieldValidationError; private double minEntrypointConfidence; private string? statusExplainField; private bool statusLogPath; @@ -517,6 +518,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r SlowQueryMs = slowQueryMs, Compact = compact, InspectFields = inspectFields, + InspectFieldValidationError = inspectFieldValidationError, MinEntrypointConfidence = minEntrypointConfidence, StatusExplainField = statusExplainField, StatusLogPath = statusLogPath, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index 1895243ca..7b7b180d1 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -23,6 +23,23 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions options.Lang); if (TryWriteUnsupportedOptionError("inspect", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("inspect"), options.Query)) return CommandExitCodes.UsageError; + if (options.InspectFieldValidationError is { } inspectFieldError) + { + return CommandErrorWriter.WriteJsonOrHuman( + json: true, + jsonOptions, + inspectFieldError.Message, + CommandExitCodes.UsageError, + inspectFieldError.Hint, + GetUsageLineOrThrow("inspect"), + CommandErrorCodes.UsageError, + category: "usage", + command: "inspect", + additionalJsonProperties: new JsonObject + { + ["field_catalog"] = ProjectionFieldRegistry.CreateInspectDiscoveryDocument(), + }); + } if (TryWriteNonPositiveCoordinateRangeError( options, jsonOptions, @@ -37,6 +54,16 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteUnsupportedOutputFormat("inspect", options, InspectOutputFormats, "Use `--format json` or `--format compact` for inspect bundles; count output is not meaningful for one inspect bundle.")) return CommandExitCodes.UsageError; + if (ProjectionFieldRegistry.IsInspectDiscoveryRequest(options.InspectFields)) + { + return WriteJsonPayloadWithOptionalByteLimit( + ProjectionFieldRegistry.CreateInspectDiscoveryDocument(), + options, + jsonOptions, + "inspect", + "inspect", + "Increase --max-json-bytes or omit it to read the complete inspect field catalog."); + } if (!TryResolveNameExactMode(options, "inspect", out var exact, out var exactError)) { CommandErrorWriter.WriteStderr(exactError); @@ -258,10 +285,10 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions sourceExcerpt.SemanticTokens = BuildExcerptSemanticTokens(sourceExcerpt, reader); payload["source_excerpt"] = JsonSerializer.SerializeToNode(sourceExcerpt, CliJsonSerializerContextFactory.Create(jsonOptions).FileExcerptResult); } + ApplyInspectDefinitionContentPolicy(payload, options); ApplyInspectFieldSelection(payload, options, jsonOptions); if (options.GroupPartials) AddInspectLogicalPartialJsonFields(payload, analysis); - ApplyInspectDefinitionContentPolicy(payload, options); AddInspectBodyModeJsonFields(payload, options, analysis); var writeExitCode = WriteJsonPayloadWithOptionalByteLimit( payload, @@ -575,10 +602,13 @@ private static void ApplyInspectFieldSelection(JsonObject payload, QueryCommandO foreach (var propertyName in payload.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) payload.Remove(propertyName); + + ApplyInspectCollectionFieldSelection(payload, options.InspectFields); } private static void AddInspectFieldProperties(HashSet keep, string field) { + field = GetInspectTopLevelField(field); if (field is "graph" or "definitions" or "references" or "callers" or "callees" or "candidates") { keep.Add("candidate_count"); @@ -644,6 +674,82 @@ private static void AddInspectFieldProperties(HashSet keep, string field } } + private static void ApplyInspectCollectionFieldSelection( + JsonObject payload, + IReadOnlyList inspectFields) + { + foreach (var collectionName in new[] + { + "definitions", "nearby_symbols", "references", "callers", "callees", + }) + { + if (inspectFields.Contains(collectionName, StringComparer.Ordinal)) + continue; + + var prefix = collectionName + "."; + var selectedLeaves = inspectFields + .Where(field => field.StartsWith(prefix, StringComparison.Ordinal)) + .Select(field => field[prefix.Length..]) + .ToList(); + if (selectedLeaves.Count == 0 + || !payload.TryGetPropertyValue(collectionName, out var collectionNode) + || collectionNode is not JsonArray rows) + { + continue; + } + + if (string.Equals(collectionName, "definitions", StringComparison.Ordinal) + && selectedLeaves.Any(IsInspectDefinitionBodyField)) + { + foreach (var recoveryField in InspectDefinitionBodyRecoveryFields) + { + if (!selectedLeaves.Contains(recoveryField, StringComparer.Ordinal)) + selectedLeaves.Add(recoveryField); + } + } + + var projectedRows = new JsonArray(); + foreach (var rowNode in rows) + { + if (rowNode is not JsonObject row) + continue; + + var projectedRow = new JsonObject(); + foreach (var leaf in selectedLeaves) + { + if (row.TryGetPropertyValue(leaf, out var value)) + projectedRow[leaf] = value?.DeepClone(); + } + projectedRows.Add(projectedRow); + } + payload[collectionName] = projectedRows; + } + } + + private static readonly string[] InspectDefinitionBodyRecoveryFields = + [ + "body_content_start_line", + "body_content_end_line", + "body_content_next_start_line", + "body_content_truncated", + "body_requested_start_line", + "body_requested_end_line", + "body_effective_start_line", + "body_effective_end_line", + "body_content_truncation_reasons", + "body_content_recovery", + ]; + + private static bool IsInspectDefinitionBodyField(string field) + => string.Equals(field, "body_content", StringComparison.Ordinal) + || field.StartsWith("body_", StringComparison.Ordinal); + + private static string GetInspectTopLevelField(string field) + { + var separator = field.IndexOf('.'); + return separator < 0 ? field : field[..separator]; + } + private static void FilterInspectCompactTruncationSections(JsonObject payload, IReadOnlyCollection inspectFields) { if (!payload.TryGetPropertyValue("truncation", out var truncationNode) @@ -655,9 +761,12 @@ private static void FilterInspectCompactTruncationSections(JsonObject payload, I } var keepSections = inspectFields + .Select(GetInspectTopLevelField) .Where(IsInspectListField) .ToHashSet(StringComparer.Ordinal); - var keepCandidateSections = inspectFields.Contains("candidates", StringComparer.Ordinal); + var keepCandidateSections = inspectFields + .Select(GetInspectTopLevelField) + .Contains("candidates", StringComparer.Ordinal); foreach (var sectionName in sections.Select(section => section.Key) .Where(section => !keepSections.Contains(section) && !(keepCandidateSections && section.StartsWith("candidate_bundles[", StringComparison.Ordinal))) @@ -714,8 +823,8 @@ private static void WriteInspectBodyModeHint(SymbolAnalysisResult analysis, Quer private static bool IsInspectDefinitionsOnlyMode(QueryCommandOptions options) => options.IncludeBody - && options.InspectFields is { Count: 1 } fields - && string.Equals(fields[0], "definitions", StringComparison.Ordinal); + && options.InspectFields is { Count: > 0 } fields + && fields.All(field => string.Equals(GetInspectTopLevelField(field), "definitions", StringComparison.Ordinal)); private static string BuildInspectBodyModeHint(QueryCommandOptions options, bool bodyContentPresent, bool bodyContentTruncated) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b87b36282..4f38c3f0b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -190,6 +190,7 @@ public sealed class QueryCommandOptions public int? SlowQueryMs { get; init; } public bool Compact { get; init; } public List? InspectFields { get; init; } + internal ProjectionFieldValidationError? InspectFieldValidationError { 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 606f974fa..74fdd745d 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -338,6 +338,9 @@ public void PrintCommandUsage_InspectSplitsQueryAndLinePathModes_Issue3916() line.Contains("--path ", StringComparison.Ordinal) && line.Contains("--path ", StringComparison.Ordinal)); Assert.Contains("In query mode --path is a glob filter", output); + Assert.All(usageLines, line => Assert.Contains("--fields ", line, StringComparison.Ordinal)); + Assert.Contains("collection.field leaves", output, StringComparison.Ordinal); + Assert.Contains("--fields list prints the machine-readable catalog", output, StringComparison.Ordinal); } [Fact] @@ -421,8 +424,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() AssertSearchUsageFragments(output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); - Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); + Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]", output); Assert.Contains("--snippet-lines ", output); Assert.Contains("Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", output); @@ -453,6 +456,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx hotspots --group-by-name --exclude-tests", output); Assert.Contains("Collapse same-name hotspots across files", output); Assert.Contains("cdidx symbols Run --exact-name Exact symbol-name match", output); + Assert.Contains("cdidx inspect Run --json --fields definitions.name,definitions.path,references.line", output); + Assert.Contains("cdidx inspect --fields list", output); Assert.Contains("backfill-fold", output); Assert.Contains("optimize Optimize FTS5 segments in an existing index DB", output); Assert.Contains("find Find literal substring matches inside known indexed files", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 957e4ad88..aef43316b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -992,17 +992,20 @@ public void RunInspect_ParseFields_ImplyJsonAndCanonicalizeAliases_Issue3056() } [Fact] - public void RunInspect_InvalidFieldsEmitsOnlyPrimaryDiagnostic_Issue4574() + public void RunInspect_InvalidFieldsEmitOneStructuredCatalogDiagnostic_Issues4574_5098() { var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( ["QueryCommandRunner", "--fields", "nope"], _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Empty(stdout); - Assert.Contains("unsupported --fields value 'nope'", stderr, StringComparison.Ordinal); - Assert.DoesNotContain("--fields requires at least one field name", stderr, StringComparison.Ordinal); - Assert.Single(stderr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries), line => line.StartsWith("Error:", StringComparison.Ordinal)); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var error = document.RootElement; + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Contains("Unknown --fields value 'nope'", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("--fields requires at least one field name", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Equal("inspect", error.GetProperty("field_catalog").GetProperty("command").GetString()); } [Fact] @@ -1393,6 +1396,201 @@ public void Compute() { } } } + [Fact] + public void RunInspect_NestedFields_ProjectRowsPreserveMetadataAndReduceBytes_Issue5098() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_nested_fields_5098"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.Part1.cs", + "csharp", + """ + public partial class Target + { + public int Compute() + { + var value = Helper(); + return value; + } + + private int Helper() => 1; + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.Part2.cs", + "csharp", + """ + public partial class Target + { + public int Invoke() => Compute(); + public int InvokeAgain() => Compute(); + } + """); + MarkGraphAndFoldReady(dbPath); + + var nestedArgs = new[] + { + "Compute", "--db", dbPath, "--json", "--exact-name", "--compact", "--limit", "1", + "--fields", + "DEFINITIONS.NAME,definitions.name,definitions.path,references.path,references.line,callers.file,callers.path,callers.line,callees.file,callees.line", + }; + var (nestedExitCode, nestedStdout, nestedStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(nestedArgs, _jsonOptions)); + using var nestedDocument = ParseJsonOutput(nestedStdout); + var nested = nestedDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, nestedExitCode); + Assert.Equal(string.Empty, nestedStderr); + Assert.Equal( + [ + "definitions.name", "definitions.path", "references.path", "references.line", + "callers.path", "callers.first_line", "callees.path", "callees.first_line", + ], + nested.GetProperty("selected_fields").EnumerateArray().Select(field => field.GetString())); + Assert.Equal( + ["name", "path"], + Assert.Single(nested.GetProperty("definitions").EnumerateArray()) + .EnumerateObject().Select(property => property.Name)); + Assert.Equal( + ["path", "line"], + Assert.Single(nested.GetProperty("references").EnumerateArray()) + .EnumerateObject().Select(property => property.Name)); + Assert.Equal( + ["path", "first_line"], + Assert.Single(nested.GetProperty("callers").EnumerateArray()) + .EnumerateObject().Select(property => property.Name)); + Assert.Equal( + ["path", "first_line"], + Assert.Single(nested.GetProperty("callees").EnumerateArray()) + .EnumerateObject().Select(property => property.Name)); + Assert.True(nested.GetProperty("graph_sections").GetProperty("references").GetProperty("truncated").GetBoolean()); + Assert.True(nested.GetProperty("truncation").GetProperty("sections").TryGetProperty("references", out _)); + + var (fullExitCode, fullStdout, fullStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--exact-name", "--limit", "1"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, fullExitCode); + Assert.Equal(string.Empty, fullStderr); + Assert.True(nestedStdout.Length * 2 < fullStdout.Length); + + var boundedArgs = nestedArgs.Concat(["--max-json-bytes", "4096"]).ToArray(); + var (boundedExitCode, boundedStdout, boundedStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(boundedArgs, _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, boundedExitCode); + Assert.Equal(string.Empty, boundedStderr); + Assert.True(System.Text.Encoding.UTF8.GetByteCount(boundedStdout) <= 4096); + + var (parentExitCode, parentStdout, parentStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,definitions"], + _jsonOptions)); + using var parentDocument = ParseJsonOutput(parentStdout); + var parentDefinition = Assert.Single(parentDocument.RootElement.GetProperty("definitions").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, parentExitCode); + Assert.Equal(string.Empty, parentStderr); + Assert.True(parentDefinition.TryGetProperty("kind", out _)); + Assert.True(parentDefinition.TryGetProperty("start_line", out _)); + + var (bodyExitCode, bodyStdout, bodyStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--exact-name", "--body-lines", "1", "--fields", "definitions.name,definitions.body"], + _jsonOptions)); + using var bodyDocument = ParseJsonOutput(bodyStdout); + var bodyDefinition = Assert.Single(bodyDocument.RootElement.GetProperty("definitions").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, bodyExitCode); + Assert.Equal(string.Empty, bodyStderr); + Assert.Equal( + ["definitions.name", "definitions.body_content"], + bodyDocument.RootElement.GetProperty("selected_fields").EnumerateArray().Select(field => field.GetString())); + Assert.Equal("name", bodyDefinition.EnumerateObject().First().Name); + Assert.True(bodyDefinition.TryGetProperty("body_content", out _)); + Assert.True(bodyDefinition.GetProperty("body_content_truncated").GetBoolean()); + Assert.True(bodyDefinition.TryGetProperty("body_content_next_start_line", out _)); + Assert.True(bodyDefinition.TryGetProperty("body_content_recovery", out _)); + + var (partialExitCode, partialStdout, partialStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + [ + "Target", "--db", dbPath, "--json", "--exact-name", "--group-partials", + "--fields", "definitions.name,definitions.definition_sites,definitions.partial_family_id,definitions.family_members", + ], + _jsonOptions)); + using var partialDocument = ParseJsonOutput(partialStdout); + var partialRoot = partialDocument.RootElement; + var partialDefinition = Assert.Single(partialRoot.GetProperty("definitions").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, partialExitCode); + Assert.Equal(string.Empty, partialStderr); + Assert.Equal(2, partialDefinition.GetProperty("definition_sites").GetInt32()); + Assert.False(string.IsNullOrWhiteSpace(partialDefinition.GetProperty("partial_family_id").GetString())); + Assert.Equal(2, partialDefinition.GetProperty("family_members").GetArrayLength()); + Assert.True(partialRoot.GetProperty("group_partials").GetBoolean()); + Assert.Equal("logical_partial_families", partialRoot.GetProperty("definition_result_scope").GetString()); + + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["MissingIssue5098", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,references.path"], + _jsonOptions)); + using var emptyDocument = ParseJsonOutput(emptyStdout); + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + Assert.Empty(emptyDocument.RootElement.GetProperty("definitions").EnumerateArray()); + Assert.Empty(emptyDocument.RootElement.GetProperty("references").EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunInspect_FieldCatalogAndUnknownNestedFieldsAreStructured_Issue5098() + { + var (catalogExitCode, catalogStdout, catalogStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(["--fields", "list"], _jsonOptions)); + using var catalogDocument = ParseJsonOutput(catalogStdout); + var catalog = catalogDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, catalogExitCode); + Assert.Equal(string.Empty, catalogStderr); + Assert.Equal("inspect", catalog.GetProperty("command").GetString()); + Assert.False(catalog.GetProperty("case_sensitive").GetBoolean()); + Assert.Equal("parent_selector_returns_full_rows", catalog.GetProperty("parent_child_behavior").GetString()); + Assert.Contains( + catalog.GetProperty("fields").EnumerateArray(), + field => field.GetProperty("name").GetString() == "definitions" + && field.GetProperty("kind").GetString() == "collection"); + Assert.Contains( + catalog.GetProperty("fields").EnumerateArray(), + field => field.GetProperty("name").GetString() == "definitions.name" + && field.GetProperty("collection").GetString() == "definitions"); + Assert.Contains( + catalog.GetProperty("fields").EnumerateArray(), + field => field.GetProperty("name").GetString() == "callers.line" + && field.GetProperty("alias_for").GetString() == "callers.first_line"); + var outline = Assert.Single( + catalog.GetProperty("fields").EnumerateArray(), + field => field.GetProperty("name").GetString() == "outline"); + Assert.Equal( + ["file", "definitions", "nearby_symbols"], + outline.GetProperty("expands_to").EnumerateArray().Select(field => field.GetString())); + + foreach (var invalidField in new[] { "unknown.path", "definitions.nope" }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Target", "--fields", invalidField], + _jsonOptions)); + using var errorDocument = ParseJsonOutput(stdout); + var error = errorDocument.RootElement; + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Equal("inspect", error.GetProperty("field_catalog").GetProperty("command").GetString()); + Assert.Contains("--fields list", error.GetProperty("hint").GetString(), StringComparison.Ordinal); + } + } + [Fact] public void RunInspect_GraphFieldsRetainInferredLanguageProvenance_Issue4727() { From 498b281753f200682c686a12d8535a69e16befae Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 14:04:58 +0900 Subject: [PATCH 2/3] Fix inspect projection edge cases (#5098) --- changelog.d/unreleased/5098.added.md | 5 +- src/CodeIndex/Cli/ProjectionFieldRegistry.cs | 25 ++++- ...eryCommandRunner.ArgumentParser.Filters.cs | 3 +- .../Cli/QueryCommandRunner.Inspect.cs | 19 ++-- .../QueryCommandRunnerInspectTests.cs | 97 +++++++++++++------ 5 files changed, 107 insertions(+), 42 deletions(-) diff --git a/changelog.d/unreleased/5098.added.md b/changelog.d/unreleased/5098.added.md index 32153a166..9f268a421 100644 --- a/changelog.d/unreleased/5098.added.md +++ b/changelog.d/unreleased/5098.added.md @@ -4,6 +4,7 @@ issues: - 5098 affected: - src/CodeIndex/Cli/ProjectionFieldRegistry.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs - USER_GUIDE.md @@ -13,8 +14,8 @@ affected: ## English -- **Inspect JSON supports nested collection field projections (#5098)** — `cdidx inspect --fields` can now select one-level leaves such as `definitions.name`, `references.path`, and `callers.line`, while preserving response metadata, compact and byte-bounded behavior, body recovery fields, deterministic parent/alias/order semantics, typed unknown-field errors, and a queryless `--fields list` catalog. +- **Inspect JSON supports nested collection field projections (#5098)** — `cdidx inspect --fields` can now select one-level leaves such as `definitions.name`, `references.path`, and `callers.line`, while preserving response metadata, compact and byte-bounded behavior, definition-body recovery fields, deterministic parent/alias/order semantics, typed unknown-field errors, and a queryless `--fields list` catalog. ## 日本語 -- **inspect JSON で collection の nested field projection に対応しました (#5098)** — `cdidx inspect --fields` で `definitions.name`、`references.path`、`callers.line` など 1 階層の leaf を選択できるようになりました。response metadata、compact / byte 上限制御、body recovery field、決定的な parent / alias / 順序の規則、未知 field の型付き error、query 不要の `--fields list` catalog も維持します。 +- **inspect JSON で collection の nested field projection に対応しました (#5098)** — `cdidx inspect --fields` で `definitions.name`、`references.path`、`callers.line` など 1 階層の leaf を選択できるようになりました。response metadata、compact / byte 上限制御、definition body の recovery field、決定的な parent / alias / 順序の規則、未知 field の型付き error、query 不要の `--fields list` catalog も維持します。 diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs index 7f391de71..3b46c8a38 100644 --- a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -195,10 +195,17 @@ internal static bool TryResolveInspectSelector( canonicalSelector = definition.AliasFor ?? definition.Name; expansion = definition.ExpandsTo; includeBody = string.Equals(normalized, "body", StringComparison.Ordinal) - || canonicalSelector.StartsWith("definitions.body_", StringComparison.Ordinal); + || canonicalSelector.StartsWith("definitions.", StringComparison.Ordinal) + && IsInspectDefinitionBodyContentField(canonicalSelector["definitions.".Length..]); return true; } + internal static bool IsInspectDefinitionBodyContentField(string field) + => string.Equals(field, "body_content", StringComparison.Ordinal) + || field.StartsWith("body_", StringComparison.Ordinal) + && !string.Equals(field, "body_start_line", StringComparison.Ordinal) + && !string.Equals(field, "body_end_line", StringComparison.Ordinal); + internal static JsonObject CreateInspectDiscoveryDocument() { var document = CreateDiscoveryDocument(InspectSchema, caseSensitive: false); @@ -254,9 +261,15 @@ private static ProjectionCommandFieldSchema CreateInspectSchema() .Distinct(StringComparer.Ordinal) .ToArray(); var nearbyFields = GetJsonFieldNames().ToArray(); - var referenceFields = GetJsonFieldNames().ToArray(); - var callerFields = GetJsonFieldNames().ToArray(); - var calleeFields = GetJsonFieldNames().ToArray(); + var referenceFields = GetJsonFieldNames() + .Where(field => !IsInspectGraphBodyField(field)) + .ToArray(); + var callerFields = GetJsonFieldNames() + .Where(field => !IsInspectGraphBodyField(field)) + .ToArray(); + var calleeFields = GetJsonFieldNames() + .Where(field => !IsInspectGraphBodyField(field)) + .ToArray(); return Create( "inspect", @@ -293,6 +306,10 @@ private static ProjectionCommandFieldSchema CreateInspectSchema() .Expansion("outlineonly", ["file", "definitions", "nearby_symbols"])); } + private static bool IsInspectGraphBodyField(string field) + => string.Equals(field, "body_content", StringComparison.Ordinal) + || field.StartsWith("body_", StringComparison.Ordinal); + private static ProjectionCommandFieldSchema CreateSearchSchema() => Create( "search", diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs index 0e88bf1c3..207ba73f5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs @@ -127,7 +127,8 @@ private bool TryParseFilterOption(string normalizedArg, string currentArg, strin fieldsValue!, AddParseError, out var includeBodyFromFields, - out inspectFieldValidationError); + out var currentInspectFieldValidationError); + inspectFieldValidationError ??= currentInspectFieldValidationError; includeBody |= includeBodyFromFields; json = true; outputFormat = OutputFormatJson; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index 7b7b180d1..0036a18b0 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -25,8 +25,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (options.InspectFieldValidationError is { } inspectFieldError) { - return CommandErrorWriter.WriteJsonOrHuman( - json: true, + var errorPayload = CommandErrorWriter.BuildJsonPayload( jsonOptions, inspectFieldError.Message, CommandExitCodes.UsageError, @@ -39,6 +38,16 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions { ["field_catalog"] = ProjectionFieldRegistry.CreateInspectDiscoveryDocument(), }); + var writeExitCode = WriteJsonPayloadWithOptionalByteLimit( + errorPayload, + options, + jsonOptions, + "inspect", + "inspect field validation error", + "Increase --max-json-bytes, or run `cdidx inspect --fields list` separately before retrying the corrected selector."); + return writeExitCode == CommandExitCodes.Success + ? CommandExitCodes.UsageError + : writeExitCode; } if (TryWriteNonPositiveCoordinateRangeError( options, @@ -699,7 +708,7 @@ private static void ApplyInspectCollectionFieldSelection( } if (string.Equals(collectionName, "definitions", StringComparison.Ordinal) - && selectedLeaves.Any(IsInspectDefinitionBodyField)) + && selectedLeaves.Any(ProjectionFieldRegistry.IsInspectDefinitionBodyContentField)) { foreach (var recoveryField in InspectDefinitionBodyRecoveryFields) { @@ -740,10 +749,6 @@ private static void ApplyInspectCollectionFieldSelection( "body_content_recovery", ]; - private static bool IsInspectDefinitionBodyField(string field) - => string.Equals(field, "body_content", StringComparison.Ordinal) - || field.StartsWith("body_", StringComparison.Ordinal); - private static string GetInspectTopLevelField(string field) { var separator = field.IndexOf('.'); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index aef43316b..106b4299a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -1438,8 +1438,7 @@ public partial class Target "--fields", "DEFINITIONS.NAME,definitions.name,definitions.path,references.path,references.line,callers.file,callers.path,callers.line,callees.file,callees.line", }; - var (nestedExitCode, nestedStdout, nestedStderr) = CaptureConsole(() => - QueryCommandRunner.RunInspect(nestedArgs, _jsonOptions)); + var (nestedExitCode, nestedStdout, nestedStderr) = RunInspectInProcess(nestedArgs); using var nestedDocument = ParseJsonOutput(nestedStdout); var nested = nestedDocument.RootElement; @@ -1470,23 +1469,20 @@ public partial class Target Assert.True(nested.GetProperty("graph_sections").GetProperty("references").GetProperty("truncated").GetBoolean()); Assert.True(nested.GetProperty("truncation").GetProperty("sections").TryGetProperty("references", out _)); - var (fullExitCode, fullStdout, fullStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["Compute", "--db", dbPath, "--json", "--exact-name", "--limit", "1"], - _jsonOptions)); + var (fullExitCode, fullStdout, fullStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--limit", "1"); Assert.Equal(CommandExitCodes.Success, fullExitCode); Assert.Equal(string.Empty, fullStderr); Assert.True(nestedStdout.Length * 2 < fullStdout.Length); var boundedArgs = nestedArgs.Concat(["--max-json-bytes", "4096"]).ToArray(); - var (boundedExitCode, boundedStdout, boundedStderr) = CaptureConsole(() => - QueryCommandRunner.RunInspect(boundedArgs, _jsonOptions)); + var (boundedExitCode, boundedStdout, boundedStderr) = RunInspectInProcess(boundedArgs); Assert.Equal(CommandExitCodes.Success, boundedExitCode); Assert.Equal(string.Empty, boundedStderr); Assert.True(System.Text.Encoding.UTF8.GetByteCount(boundedStdout) <= 4096); - var (parentExitCode, parentStdout, parentStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["Compute", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,definitions"], - _jsonOptions)); + var (parentExitCode, parentStdout, parentStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,definitions"); using var parentDocument = ParseJsonOutput(parentStdout); var parentDefinition = Assert.Single(parentDocument.RootElement.GetProperty("definitions").EnumerateArray()); Assert.Equal(CommandExitCodes.Success, parentExitCode); @@ -1494,9 +1490,8 @@ public partial class Target Assert.True(parentDefinition.TryGetProperty("kind", out _)); Assert.True(parentDefinition.TryGetProperty("start_line", out _)); - var (bodyExitCode, bodyStdout, bodyStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["Compute", "--db", dbPath, "--json", "--exact-name", "--body-lines", "1", "--fields", "definitions.name,definitions.body"], - _jsonOptions)); + var (bodyExitCode, bodyStdout, bodyStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--body-lines", "1", "--fields", "definitions.name,definitions.body"); using var bodyDocument = ParseJsonOutput(bodyStdout); var bodyDefinition = Assert.Single(bodyDocument.RootElement.GetProperty("definitions").EnumerateArray()); Assert.Equal(CommandExitCodes.Success, bodyExitCode); @@ -1510,12 +1505,26 @@ public partial class Target Assert.True(bodyDefinition.TryGetProperty("body_content_next_start_line", out _)); Assert.True(bodyDefinition.TryGetProperty("body_content_recovery", out _)); - var (partialExitCode, partialStdout, partialStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - [ - "Target", "--db", dbPath, "--json", "--exact-name", "--group-partials", - "--fields", "definitions.name,definitions.definition_sites,definitions.partial_family_id,definitions.family_members", - ], - _jsonOptions)); + foreach (var boundaryField in new[] { "body_start_line", "body_end_line" }) + { + var selector = $"definitions.{boundaryField}"; + var (boundaryExitCode, boundaryStdout, boundaryStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--fields", selector); + using var boundaryDocument = ParseJsonOutput(boundaryStdout); + var boundaryRoot = boundaryDocument.RootElement; + var boundaryDefinition = Assert.Single(boundaryRoot.GetProperty("definitions").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, boundaryExitCode); + Assert.Equal(string.Empty, boundaryStderr); + Assert.Equal([selector], boundaryRoot.GetProperty("selected_fields").EnumerateArray().Select(field => field.GetString())); + Assert.Equal([boundaryField], boundaryDefinition.EnumerateObject().Select(property => property.Name)); + Assert.False(boundaryRoot.GetProperty("body_mode").GetProperty("include_body").GetBoolean()); + Assert.False(boundaryDefinition.TryGetProperty("body_content_recovery", out _)); + } + + var (partialExitCode, partialStdout, partialStderr) = RunInspectInProcess( + "Target", "--db", dbPath, "--json", "--exact-name", "--group-partials", + "--fields", "definitions.name,definitions.definition_sites,definitions.partial_family_id,definitions.family_members"); using var partialDocument = ParseJsonOutput(partialStdout); var partialRoot = partialDocument.RootElement; var partialDefinition = Assert.Single(partialRoot.GetProperty("definitions").EnumerateArray()); @@ -1527,9 +1536,8 @@ public partial class Target Assert.True(partialRoot.GetProperty("group_partials").GetBoolean()); Assert.Equal("logical_partial_families", partialRoot.GetProperty("definition_result_scope").GetString()); - var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["MissingIssue5098", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,references.path"], - _jsonOptions)); + var (emptyExitCode, emptyStdout, emptyStderr) = RunInspectInProcess( + "MissingIssue5098", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,references.path"); using var emptyDocument = ParseJsonOutput(emptyStdout); Assert.Equal(CommandExitCodes.Success, emptyExitCode); Assert.Equal(string.Empty, emptyStderr); @@ -1545,8 +1553,7 @@ public partial class Target [Fact] public void RunInspect_FieldCatalogAndUnknownNestedFieldsAreStructured_Issue5098() { - var (catalogExitCode, catalogStdout, catalogStderr) = CaptureConsole(() => - QueryCommandRunner.RunInspect(["--fields", "list"], _jsonOptions)); + var (catalogExitCode, catalogStdout, catalogStderr) = RunInspectInProcess("--fields", "list"); using var catalogDocument = ParseJsonOutput(catalogStdout); var catalog = catalogDocument.RootElement; @@ -1573,12 +1580,16 @@ public void RunInspect_FieldCatalogAndUnknownNestedFieldsAreStructured_Issue5098 Assert.Equal( ["file", "definitions", "nearby_symbols"], outline.GetProperty("expands_to").EnumerateArray().Select(field => field.GetString())); + var catalogFieldNames = catalog.GetProperty("fields").EnumerateArray() + .Select(field => field.GetProperty("name").GetString()) + .ToArray(); + Assert.DoesNotContain("references.body_content", catalogFieldNames); + Assert.DoesNotContain("callers.body_content", catalogFieldNames); + Assert.DoesNotContain("callees.body_content", catalogFieldNames); - foreach (var invalidField in new[] { "unknown.path", "definitions.nope" }) + foreach (var invalidField in new[] { "unknown.path", "definitions.nope", "references.body_content" }) { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( - ["Target", "--fields", invalidField], - _jsonOptions)); + var (exitCode, stdout, stderr) = RunInspectInProcess("Target", "--fields", invalidField); using var errorDocument = ParseJsonOutput(stdout); var error = errorDocument.RootElement; @@ -1589,6 +1600,36 @@ public void RunInspect_FieldCatalogAndUnknownNestedFieldsAreStructured_Issue5098 Assert.Equal("inspect", error.GetProperty("field_catalog").GetProperty("command").GetString()); Assert.Contains("--fields list", error.GetProperty("hint").GetString(), StringComparison.Ordinal); } + + var (boundedExitCode, boundedStdout, boundedStderr) = RunInspectInProcess( + "Target", "--fields", "definitions.nope", "--max-json-bytes", "4096"); + using var boundedDocument = ParseJsonOutput(boundedStdout); + Assert.Equal(CommandExitCodes.UsageError, boundedExitCode); + Assert.Equal(string.Empty, boundedStderr); + Assert.True(System.Text.Encoding.UTF8.GetByteCount(boundedStdout) <= 4096); + Assert.Equal( + CommandErrorCodes.ResponseBudgetTooSmall, + boundedDocument.RootElement.GetProperty("error_code").GetString()); + + foreach (var repeatedFields in new[] + { + new[] { "--fields", "definitions.nope", "--fields", "definitions.name" }, + new[] { "--fields", "definitions.name", "--fields", "definitions.nope" }, + }) + { + var repeatedArgs = new[] { "Target" }.Concat(repeatedFields).ToArray(); + var (repeatedExitCode, repeatedStdout, repeatedStderr) = RunInspectInProcess(repeatedArgs); + using var repeatedDocument = ParseJsonOutput(repeatedStdout); + + Assert.Equal(CommandExitCodes.UsageError, repeatedExitCode); + Assert.Contains("Warning: --fields specified more than once", repeatedStderr, StringComparison.Ordinal); + Assert.Equal( + CommandErrorCodes.UsageError, + repeatedDocument.RootElement.GetProperty("error_code").GetString()); + Assert.Equal( + "inspect", + repeatedDocument.RootElement.GetProperty("field_catalog").GetProperty("command").GetString()); + } } [Fact] From fe6ecc55ef48d366e322092b88838383a7b8517b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 14:29:51 +0900 Subject: [PATCH 3/3] Preserve inspect projection metadata (#5098) --- changelog.d/unreleased/5098.added.md | 5 +- ...eryCommandRunner.ArgumentParser.Filters.cs | 2 +- .../Cli/QueryCommandRunner.ArgumentParser.cs | 3 +- .../Cli/QueryCommandRunner.Inspect.cs | 61 +++++++++++++ .../QueryCommandRunnerInspectTests.cs | 89 +++++++++++++++++++ 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/changelog.d/unreleased/5098.added.md b/changelog.d/unreleased/5098.added.md index 9f268a421..7f961a270 100644 --- a/changelog.d/unreleased/5098.added.md +++ b/changelog.d/unreleased/5098.added.md @@ -5,6 +5,7 @@ issues: affected: - src/CodeIndex/Cli/ProjectionFieldRegistry.cs - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs - USER_GUIDE.md @@ -14,8 +15,8 @@ affected: ## English -- **Inspect JSON supports nested collection field projections (#5098)** — `cdidx inspect --fields` can now select one-level leaves such as `definitions.name`, `references.path`, and `callers.line`, while preserving response metadata, compact and byte-bounded behavior, definition-body recovery fields, deterministic parent/alias/order semantics, typed unknown-field errors, and a queryless `--fields list` catalog. +- **Inspect JSON supports nested collection field projections (#5098)** — `cdidx inspect --fields` can now select one-level leaves such as `definitions.name`, `references.path`, and `callers.line`, while preserving response and coupled row truncation metadata, compact and byte-bounded behavior, definition-body recovery fields, deterministic parent/alias/order and repeated-option semantics, typed unknown-field errors, and a queryless `--fields list` catalog. ## 日本語 -- **inspect JSON で collection の nested field projection に対応しました (#5098)** — `cdidx inspect --fields` で `definitions.name`、`references.path`、`callers.line` など 1 階層の leaf を選択できるようになりました。response metadata、compact / byte 上限制御、definition body の recovery field、決定的な parent / alias / 順序の規則、未知 field の型付き error、query 不要の `--fields list` catalog も維持します。 +- **inspect JSON で collection の nested field projection に対応しました (#5098)** — `cdidx inspect --fields` で `definitions.name`、`references.path`、`callers.line` など 1 階層の leaf を選択できるようになりました。response metadata と行内の関連 truncation metadata、compact / byte 上限制御、definition body の recovery field、決定的な parent / alias / 順序 / 重複 option の規則、未知 field の型付き error、query 不要の `--fields list` catalog も維持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs index 207ba73f5..24151a4dd 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs @@ -129,7 +129,7 @@ private bool TryParseFilterOption(string normalizedArg, string currentArg, strin out var includeBodyFromFields, out var currentInspectFieldValidationError); inspectFieldValidationError ??= currentInspectFieldValidationError; - includeBody |= includeBodyFromFields; + inspectFieldsIncludeBody = includeBodyFromFields; json = true; outputFormat = OutputFormatJson; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index f1d9579f9..f066eb377 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -124,6 +124,7 @@ private sealed partial class QueryArgumentParser private int? slowQueryMs; private bool compact; private List? inspectFields; + private bool inspectFieldsIncludeBody; private ProjectionFieldValidationError? inspectFieldValidationError; private double minEntrypointConfidence; private string? statusExplainField; @@ -432,7 +433,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r Severity = severity, Query = query, RawFts = rawFts, - IncludeBody = includeBody, + IncludeBody = includeBody || inspectFieldsIncludeBody, BodyStartLine = bodyStartLine, BodyLines = bodyLines, StartLine = startLine, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index 0036a18b0..eead2774d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -716,6 +716,7 @@ private static void ApplyInspectCollectionFieldSelection( selectedLeaves.Add(recoveryField); } } + AddInspectCoupledRowMetadata(collectionName, selectedLeaves); var projectedRows = new JsonArray(); foreach (var rowNode in rows) @@ -735,6 +736,66 @@ private static void ApplyInspectCollectionFieldSelection( } } + private static void AddInspectCoupledRowMetadata( + string collectionName, + List selectedLeaves) + { + if (string.Equals(collectionName, "definitions", StringComparison.Ordinal) + || string.Equals(collectionName, "nearby_symbols", StringComparison.Ordinal)) + { + AddInspectCoupledFields( + selectedLeaves, + "signature", + "signature_truncated", + "signature_original_length"); + AddInspectCoupledFields( + selectedLeaves, + "family_members", + "definition_sites", + "family_members_truncated"); + return; + } + + if (string.Equals(collectionName, "references", StringComparison.Ordinal)) + { + AddInspectCoupledFields(selectedLeaves, "context", "context_truncated"); + return; + } + + if (!string.Equals(collectionName, "callers", StringComparison.Ordinal) + && !string.Equals(collectionName, "callees", StringComparison.Ordinal)) + { + return; + } + + foreach (var aggregateField in new[] + { + "reference_kind", "reference_kinds", "reference_kind_counts", "reference_count", + }) + { + AddInspectCoupledFields( + selectedLeaves, + aggregateField, + "has_mixed_reference_kinds", + "aggregate_truncated"); + } + } + + private static void AddInspectCoupledFields( + List selectedLeaves, + string selectedField, + params string[] coupledFields) + { + if (!selectedLeaves.Contains(selectedField, StringComparer.Ordinal)) + return; + + foreach (var coupledField in coupledFields) + { + if (!selectedLeaves.Contains(coupledField, StringComparer.Ordinal)) + selectedLeaves.Add(coupledField); + } + } + private static readonly string[] InspectDefinitionBodyRecoveryFields = [ "body_content_start_line", diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 106b4299a..bb751e35a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -1430,6 +1430,19 @@ public partial class Target public int InvokeAgain() => Compute(); } """); + var longParameterList = string.Join( + ", ", + Enumerable.Range(1, 60).Select(index => $"int parameter{index:D2}")); + var coupledMetadataFixtures = Enumerable.Range(1, 51) + .Select(index => new TestProjectHelper.IndexedFileFixture( + $"src/WidePartial.{index:D2}.cs", + "csharp", + "public partial class WidePartial { }")) + .Append(new TestProjectHelper.IndexedFileFixture( + "src/SignatureTarget.cs", + "csharp", + $"public class SignatureTarget {{ public void LongSignature({longParameterList}) {{ }} }}")); + TestProjectHelper.InsertIndexedFiles(dbPath, coupledMetadataFixtures); MarkGraphAndFoldReady(dbPath); var nestedArgs = new[] @@ -1522,6 +1535,42 @@ public partial class Target Assert.False(boundaryDefinition.TryGetProperty("body_content_recovery", out _)); } + foreach (var repeatedFieldSelection in new[] + { + new[] { "definitions.body_content", "definitions.name", "name" }, + new[] { "body", "definitions.body_start_line", "body_start_line" }, + }) + { + var (repeatedExitCode, repeatedStdout, repeatedStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", + "--fields", repeatedFieldSelection[0], "--fields", repeatedFieldSelection[1]); + using var repeatedDocument = ParseJsonOutput(repeatedStdout); + var repeatedRoot = repeatedDocument.RootElement; + var repeatedDefinition = Assert.Single(repeatedRoot.GetProperty("definitions").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, repeatedExitCode); + Assert.Contains("Warning: --fields specified more than once", repeatedStderr, StringComparison.Ordinal); + Assert.Equal( + [repeatedFieldSelection[1]], + repeatedRoot.GetProperty("selected_fields").EnumerateArray().Select(field => field.GetString())); + Assert.Equal( + [repeatedFieldSelection[2]], + repeatedDefinition.EnumerateObject().Select(property => property.Name)); + Assert.False(repeatedRoot.GetProperty("body_mode").GetProperty("include_body").GetBoolean()); + } + + var (explicitBodyExitCode, explicitBodyStdout, explicitBodyStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--body", + "--fields", "definitions.body_content", "--fields", "definitions.name"); + using var explicitBodyDocument = ParseJsonOutput(explicitBodyStdout); + Assert.Equal(CommandExitCodes.Success, explicitBodyExitCode); + Assert.Contains("Warning: --fields specified more than once", explicitBodyStderr, StringComparison.Ordinal); + Assert.True(explicitBodyDocument.RootElement.GetProperty("body_mode").GetProperty("include_body").GetBoolean()); + Assert.Equal( + ["name"], + Assert.Single(explicitBodyDocument.RootElement.GetProperty("definitions").EnumerateArray()) + .EnumerateObject().Select(property => property.Name)); + var (partialExitCode, partialStdout, partialStderr) = RunInspectInProcess( "Target", "--db", dbPath, "--json", "--exact-name", "--group-partials", "--fields", "definitions.name,definitions.definition_sites,definitions.partial_family_id,definitions.family_members"); @@ -1536,6 +1585,46 @@ public partial class Target Assert.True(partialRoot.GetProperty("group_partials").GetBoolean()); Assert.Equal("logical_partial_families", partialRoot.GetProperty("definition_result_scope").GetString()); + var (truncatedPartialExitCode, truncatedPartialStdout, truncatedPartialStderr) = RunInspectInProcess( + "WidePartial", "--db", dbPath, "--json", "--exact-name", "--group-partials", + "--fields", "definitions.family_members"); + using var truncatedPartialDocument = ParseJsonOutput(truncatedPartialStdout); + var truncatedPartialDefinition = Assert.Single( + truncatedPartialDocument.RootElement.GetProperty("definitions").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, truncatedPartialExitCode); + Assert.Equal(string.Empty, truncatedPartialStderr); + Assert.Equal(50, truncatedPartialDefinition.GetProperty("family_members").GetArrayLength()); + Assert.Equal(51, truncatedPartialDefinition.GetProperty("definition_sites").GetInt32()); + Assert.True(truncatedPartialDefinition.GetProperty("family_members_truncated").GetBoolean()); + + var (contextExitCode, contextStdout, contextStderr) = RunInspectInProcess( + "Compute", "--db", dbPath, "--json", "--exact-name", "--max-line-width", "10", + "--fields", "references.context"); + using var contextDocument = ParseJsonOutput(contextStdout); + var contextRows = contextDocument.RootElement.GetProperty("references").EnumerateArray().ToArray(); + Assert.Equal(CommandExitCodes.Success, contextExitCode); + Assert.Equal(string.Empty, contextStderr); + Assert.NotEmpty(contextRows); + Assert.All(contextRows, row => + { + Assert.Equal( + ["context", "context_truncated"], + row.EnumerateObject().Select(property => property.Name)); + Assert.True(row.GetProperty("context_truncated").GetBoolean()); + }); + + var (signatureExitCode, signatureStdout, signatureStderr) = RunInspectInProcess( + "LongSignature", "--db", dbPath, "--json", "--exact-name", + "--fields", "definitions.signature"); + using var signatureDocument = ParseJsonOutput(signatureStdout); + var signatureDefinition = Assert.Single( + signatureDocument.RootElement.GetProperty("definitions").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, signatureExitCode); + Assert.Equal(string.Empty, signatureStderr); + Assert.Equal(512, signatureDefinition.GetProperty("signature").GetString()!.Length); + Assert.True(signatureDefinition.GetProperty("signature_truncated").GetBoolean()); + Assert.True(signatureDefinition.GetProperty("signature_original_length").GetInt32() > 512); + var (emptyExitCode, emptyStdout, emptyStderr) = RunInspectInProcess( "MissingIssue5098", "--db", dbPath, "--json", "--exact-name", "--fields", "definitions.name,references.path"); using var emptyDocument = ParseJsonOutput(emptyStdout);