From 4bdf4b87db200e739f2ad87310e94159f6faf54e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 21:22:31 +0900 Subject: [PATCH 1/3] Reject invisible graph snippet lengths (#4882) --- DEVELOPER_GUIDE.md | 4 ++ TESTING_GUIDE.md | 2 + changelog.d/unreleased/4882.fixed.md | 21 +++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 39 ++++++++++++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- .../QueryCommandRunnerGraphTests.cs | 63 ++++++++++++++++++- .../QueryCommandRunnerTests.cs | 6 +- 8 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/4882.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6c7b67405..085211100 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1758,6 +1758,8 @@ Runtime diagnostic subcontracts: `references` already prefixes each human-readable row with `reference_kind`, and `callers` does the same for its grouped caller rows. When one grouped container mixes kinds (for example `call` and `subscribe` on the same event member), the human-readable label joins the distinct kinds with `+` (for example `call+subscribe`) instead of collapsing to a single preferred label, and the reference-kind column widens dynamically to fit the longest label in the batch so mixed rows do not overrun the neighbouring column. JSON output for `callers` and `callees` keeps the scalar `reference_kind` for back-compat (it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `reference_kinds` array plus a `has_mixed_reference_kinds` bool so consumers can detect mixed containers without trusting a single collapsed label. This lets terminal users distinguish `call` / `instantiate` / `subscribe` / mixed without re-running the command with `--json` and lets AI clients answer mixed-kind questions without chasing a second `--exact` query. +For `references`, `callers`, and `callees`, an explicit `--snippet-lines` is valid only with `--body` and text or JSON result output. Location-only formats and `--count` reject the option before opening the database so a requested snippet length is never silently ignored or recorded in replay/query context without being applied. + `ReferenceResult` includes `is_self_reference` and `is_mutual_recursion`; `CallerResult` includes `has_self_reference` and `has_mutual_recursion`. These fields identify self-recursive edges and direct two-symbol cycles without removing valid recursive calls from default graph results. Reader APIs that need a non-recursive view can opt into self-reference exclusion. MCP tool calls return structured JSON in `structuredContent` plus a short summary in `content`, so clients can consume typed data directly. Text content blocks include `mimeType`: `application/json` when a structured payload is present and `text/plain` otherwise. Tool input schemas also carry common JSON Schema constraints (`minimum` / `maximum` for limits and line counts, `maxLength` for free text, `pattern` for workspace-relative path filters, and `enum` for common kind values) so MCP-aware clients can reject invalid requests before dispatch. @@ -5130,6 +5132,8 @@ runtime diagnostic subcontract: `references` は以前から人間向け出力の各行先頭に `reference_kind` を表示しており、`callers` も grouped caller 行に対して同じタグを出す。1 つの grouped container で kind が混在する場合(例: 同じ event メンバに対する `call` と `subscribe`)は、単一 preferred label へ潰さずに `call+subscribe` のように distinct kind を `+` で連結して表示する。reference-kind 列の幅はバッチ内で最も長いラベルに合わせて動的に広がるため、mixed 行が隣接列を押し出さない。`callers` / `callees` の JSON 出力では、後方互換のため scalar な `reference_kind`(preferred 順 `instantiate` > `subscribe` > `MIN(call)` の要約 kind)を残しつつ、ソート済みの `reference_kinds` 配列と `has_mixed_reference_kinds` bool も追加した。これにより consumer は単一 summary label に騙されずに mixed container を検出できる。端末上でも `call` / `instantiate` / `subscribe` / mixed を `--json` なしで見分けられ、AI クライアントも `--exact` を改めて投げ直さずに mixed-kind の問いに答えられる。 +`references`、`callers`、`callees` で明示した `--snippet-lines` は、`--body` と text または JSON の結果出力を併用する場合だけ有効です。location-only format と `--count` は database を開く前にこの option を拒否するため、要求した snippet 長が黙って無視されたり、適用されないまま replay / query context に記録されたりすることはありません。 + `ReferenceResult` は `is_self_reference` と `is_mutual_recursion` を含み、`CallerResult` は `has_self_reference` と `has_mutual_recursion` を含む。これらのフィールドは、正当な再帰呼び出しを既定の graph 結果から削除せずに、自己再帰エッジと直接の2シンボル循環を識別する。非再帰 view が必要な reader API は自己参照除外を opt-in で使える。 MCPツール呼び出しは `structuredContent` に構造化JSON、`content` に短い要約を返すため、クライアントは型付きデータを直接利用できます。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index c754c89a5..cfc48ed10 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -184,6 +184,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. + Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. @@ -1116,6 +1117,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 + graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 diff --git a/changelog.d/unreleased/4882.fixed.md b/changelog.d/unreleased/4882.fixed.md new file mode 100644 index 000000000..20c20ca71 --- /dev/null +++ b/changelog.d/unreleased/4882.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4882 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Graph queries no longer silently ignore explicit snippet lengths (#4882)** — `references`, `callers`, and `callees` now reject `--snippet-lines` unless `--body` and text or JSON result output make the requested snippet visible. + +## 日本語 + +- **graph query が明示的な snippet 長を黙って無視しないようになりました (#4882)** — `references`、`callers`、`callees` は、要求した snippet を表示できる `--body` と text または JSON の結果出力を併用しない限り、`--snippet-lines` を拒否するようになりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 4582c7003..193d8b3cc 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -571,7 +571,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--no-progress", Description = "Disable animated progress and spinner output", PrimaryCommands = Set(AllCommands.ToArray()), TopLevel = true }, new() { Name = "--name", ValuePlaceholder = "", Description = "Exact symbol name", PrimaryCommands = Set("symbols") }, new() { Name = "--max-line-width", ValuePlaceholder = "", Description = "Clamp long single-line payloads (0 disables clamping)", PrimaryCommands = Set(MaxLineWidthCommands) }, - new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length; issue-drafts accept 0 for path/line-only evidence", PrimaryCommands = Set("search", "audit", "find", "references", "callers", "callees", "impact") }, + new() { Name = "--snippet-lines", ValuePlaceholder = "", Description = "Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", PrimaryCommands = Set("search", "audit", "find", "references", "callers", "callees", "impact") }, new() { Name = "--snippet-focus", ValuePlaceholder = "", Description = "Search snippet long-line focus mode", PrimaryCommands = Set("search") }, new() { Name = "--fts", Description = "Raw FTS5 syntax; incompatible with search exact/literal modes", PrimaryCommands = Set("search") }, new() { Name = "--no-dedup", Description = "Show duplicate chunks", PrimaryCommands = Set("search") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index b4f60d0f8..8f1e946c9 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -457,10 +457,49 @@ private static bool TryParseGraphCommandOptions( return false; } + if (options.ParseError == null + && !TryValidateGraphSnippetLinesOption(command, cmdArgs, options)) + { + exitCode = CommandExitCodes.UsageError; + return false; + } + exitCode = CommandExitCodes.Success; return true; } + private static bool TryValidateGraphSnippetLinesOption( + string command, + string[] cmdArgs, + QueryCommandOptions options) + { + if (!HasOption(cmdArgs, "--snippet-lines") || options.SnippetLines == 0) + return true; + + if (!options.IncludeBody) + { + CommandErrorWriter.Write( + "--snippet-lines requires --body for references, callers, and callees.", + "Add --body to emit a bounded body excerpt, or omit --snippet-lines.", + GetUsageLineOrThrow(command), + CommandErrorCodes.UsageError); + return false; + } + + if (options.CountOnly + || options.OutputFormat is not (OutputFormatText or OutputFormatJson)) + { + CommandErrorWriter.Write( + "--snippet-lines with --body requires text or JSON result output for references, callers, and callees.", + "Remove --count and use --format text or --format json, or omit --snippet-lines for location-only output.", + GetUsageLineOrThrow(command), + CommandErrorCodes.UsageError); + return false; + } + + return true; + } + private static bool TryValidateGraphSymbolQuery( string command, QueryCommandOptions options, diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index cc858e578..055e639b3 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -411,7 +411,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() 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; issue-drafts accept 0 for path/line-only evidence", output); + Assert.Contains("Snippet length; graph queries require --body with text/JSON output; issue-drafts accept 0 for path/line-only evidence", output); Assert.Contains("cdidx find (--path |--all)", output); Assert.Contains("--count", output); Assert.Contains("scan caps can still mark approximate counts as degraded", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs index 34c511d3e..eb2613626 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs @@ -622,15 +622,23 @@ int Login(int user) expectedContentTruncated: true); AssertBodyExcerpt( QueryCommandRunner.RunCallers, - ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "2"], + ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "20"], "int Login(int user)", - expectedContentTruncated: true); + expectedContentTruncated: false); AssertBodyExcerpt( QueryCommandRunner.RunCallees, ["Login", "--db", dbPath, "--json", "--body", "--snippet-lines", "1"], "int Run(int user)", expectedContentTruncated: true); + var (textExitCode, textStdout, textStderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( + ["Run", "--db", dbPath, "--body", "--snippet-lines", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, textExitCode); + Assert.Contains("int Login(int user)", textStdout); + Assert.Contains("references in", textStderr); + var (impactExitCode, impactStdout, impactStderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( ["Run", "--db", dbPath, "--json", "--body", "--snippet-lines", "2"], _jsonOptions)); @@ -649,6 +657,57 @@ int Login(int user) } } + [Fact] + public void GraphCommands_ExplicitSnippetLinesRequireVisibleBodyOutput_Issue4882() + { + var scenarios = new (string[] Args, string ExpectedMessage)[] + { + (["--snippet-lines", "3"], "--snippet-lines requires --body"), + (["--snippet-lines=3", "--json"], "--snippet-lines requires --body"), + (["--snippet-lines", "3", "--format", "qf"], "--snippet-lines requires --body"), + (["--snippet-lines", "3", "--format", "lsp"], "--snippet-lines requires --body"), + (["--snippet-lines", "3", "--format", "compact"], "--snippet-lines requires --body"), + (["--body", "--snippet-lines", "3", "--format", "qf"], "--snippet-lines with --body requires text or JSON result output"), + (["--body", "--snippet-lines", "3", "--format", "lsp"], "--snippet-lines with --body requires text or JSON result output"), + (["--body", "--snippet-lines", "3", "--format", "compact"], "--snippet-lines with --body requires text or JSON result output"), + (["--body", "--snippet-lines", "3", "--count"], "--snippet-lines with --body requires text or JSON result output"), + }; + + foreach (var command in new[] { "references", "callers", "callees" }) + { + foreach (var scenario in scenarios) + { + var args = new[] { "QueryCommandRunner" }.Concat(scenario.Args).ToArray(); + var (exitCode, stdout, stderr) = CaptureConsole(() => RunGraphCommand(command, args, _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]:", stderr); + Assert.Contains(scenario.ExpectedMessage, stderr); + Assert.Contains($"Usage: {ConsoleUi.GetUsageLine(command)}", stderr); + Assert.DoesNotContain("database not found", stderr, StringComparison.OrdinalIgnoreCase); + } + } + } + + [Fact] + public void GraphCommands_SnippetLinesAboveMaximumKeepRangeError_Issue4882() + { + foreach (var command in new[] { "references", "callers", "callees" }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => RunGraphCommand( + command, + ["QueryCommandRunner", "--snippet-lines", "21"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--snippet-lines must be less than or equal to 20, got '21'", stderr); + Assert.DoesNotContain("--snippet-lines requires --body", stderr); + Assert.DoesNotContain("database not found", stderr, StringComparison.OrdinalIgnoreCase); + } + } + diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 5e364c426..660adcf35 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -4543,7 +4543,11 @@ private void AssertBodyExcerpt( using var document = ParseJsonOutput(stdout); Assert.Contains(expectedContent, document.RootElement.GetProperty("body_content").GetString()); if (expectedContentTruncated.HasValue) - Assert.Equal(expectedContentTruncated.Value, document.RootElement.GetProperty("body_content_truncated").GetBoolean()); + { + var contentTruncated = document.RootElement.TryGetProperty("body_content_truncated", out var property) + && property.GetBoolean(); + Assert.Equal(expectedContentTruncated.Value, contentTruncated); + } } private static int CountLines(string text) => text.Split('\n').Length; From 7ee1b7e41b3f5075e08e7c72cb8d32d04b5ee67b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 22:10:19 +0900 Subject: [PATCH 2/3] Preserve graph snippet replay semantics (#4882) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4882.fixed.md | 4 + .../Cli/JsonEnvelopeWrapper.Bounded.cs | 1 + .../Cli/QueryCommandRunner.ArgumentParser.cs | 1 + src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 5 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 1 + .../JsonEnvelopeWrapperIssue4882Tests.cs | 137 ++++++++++++++++++ .../QueryCommandRunnerGraphTests.cs | 35 +++++ 9 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 085211100..cc80b9ea4 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1758,7 +1758,7 @@ Runtime diagnostic subcontracts: `references` already prefixes each human-readable row with `reference_kind`, and `callers` does the same for its grouped caller rows. When one grouped container mixes kinds (for example `call` and `subscribe` on the same event member), the human-readable label joins the distinct kinds with `+` (for example `call+subscribe`) instead of collapsing to a single preferred label, and the reference-kind column widens dynamically to fit the longest label in the batch so mixed rows do not overrun the neighbouring column. JSON output for `callers` and `callees` keeps the scalar `reference_kind` for back-compat (it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `reference_kinds` array plus a `has_mixed_reference_kinds` bool so consumers can detect mixed containers without trusting a single collapsed label. This lets terminal users distinguish `call` / `instantiate` / `subscribe` / mixed without re-running the command with `--json` and lets AI clients answer mixed-kind questions without chasing a second `--exact` query. -For `references`, `callers`, and `callees`, an explicit `--snippet-lines` is valid only with `--body` and text or JSON result output. Location-only formats and `--count` reject the option before opening the database so a requested snippet length is never silently ignored or recorded in replay/query context without being applied. +For `references`, `callers`, and `callees`, an explicit `--snippet-lines` is valid only with `--body` and text or JSON result output. Location-only formats and `--count` reject the option before opening the database so a requested snippet length is never silently ignored or recorded in replay/query context without being applied. Explicitness comes from the argument parser, so option-like literals passed through `--query` or after `--` remain queries. Bounded JSON projection also removes the snippet-only control from its internal count replay, preserving clean stderr, total counts, and cursors for visible body excerpts. `ReferenceResult` includes `is_self_reference` and `is_mutual_recursion`; `CallerResult` includes `has_self_reference` and `has_mutual_recursion`. These fields identify self-recursive edges and direct two-symbol cycles without removing valid recursive calls from default graph results. Reader APIs that need a non-recursive view can opt into self-reference exclusion. @@ -5132,7 +5132,7 @@ runtime diagnostic subcontract: `references` は以前から人間向け出力の各行先頭に `reference_kind` を表示しており、`callers` も grouped caller 行に対して同じタグを出す。1 つの grouped container で kind が混在する場合(例: 同じ event メンバに対する `call` と `subscribe`)は、単一 preferred label へ潰さずに `call+subscribe` のように distinct kind を `+` で連結して表示する。reference-kind 列の幅はバッチ内で最も長いラベルに合わせて動的に広がるため、mixed 行が隣接列を押し出さない。`callers` / `callees` の JSON 出力では、後方互換のため scalar な `reference_kind`(preferred 順 `instantiate` > `subscribe` > `MIN(call)` の要約 kind)を残しつつ、ソート済みの `reference_kinds` 配列と `has_mixed_reference_kinds` bool も追加した。これにより consumer は単一 summary label に騙されずに mixed container を検出できる。端末上でも `call` / `instantiate` / `subscribe` / mixed を `--json` なしで見分けられ、AI クライアントも `--exact` を改めて投げ直さずに mixed-kind の問いに答えられる。 -`references`、`callers`、`callees` で明示した `--snippet-lines` は、`--body` と text または JSON の結果出力を併用する場合だけ有効です。location-only format と `--count` は database を開く前にこの option を拒否するため、要求した snippet 長が黙って無視されたり、適用されないまま replay / query context に記録されたりすることはありません。 +`references`、`callers`、`callees` で明示した `--snippet-lines` は、`--body` と text または JSON の結果出力を併用する場合だけ有効です。location-only format と `--count` は database を開く前にこの option を拒否するため、要求した snippet 長が黙って無視されたり、適用されないまま replay / query context に記録されたりすることはありません。明示指定かどうかは引数 parser の状態で判定するため、`--query` の値または `--` 以降に渡した option 風の literal は query のままです。bounded JSON projection の内部 count replay からは snippet 専用 control も除去し、表示可能な body excerpt で stderr、total count、cursor を正しく維持します。 `ReferenceResult` は `is_self_reference` と `is_mutual_recursion` を含み、`CallerResult` は `has_self_reference` と `has_mutual_recursion` を含む。これらのフィールドは、正当な再帰呼び出しを既定の graph 結果から削除せずに、自己再帰エッジと直接の2シンボル循環を識別する。非再帰 view が必要な reader API は自己参照除外を opt-in で使える。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cfc48ed10..79a52ad07 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -184,7 +184,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. - Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. + Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. @@ -1117,7 +1117,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 - graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。 + graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 diff --git a/changelog.d/unreleased/4882.fixed.md b/changelog.d/unreleased/4882.fixed.md index 20c20ca71..26e63c99e 100644 --- a/changelog.d/unreleased/4882.fixed.md +++ b/changelog.d/unreleased/4882.fixed.md @@ -4,8 +4,12 @@ issues: - 4882 affected: - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs - src/CodeIndex/Cli/CliFlagSchema.cs - tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs + - tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs - tests/CodeIndex.Tests/ConsoleUiTests.cs - DEVELOPER_GUIDE.md diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index f2ff2092f..79beb7d53 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -1167,6 +1167,7 @@ private static string[] PrepareCountArgs(string command, string[] args) stripped.RemoveAll(arg => string.Equals(arg, "--body", StringComparison.Ordinal) || string.Equals(arg, "--summary-only", StringComparison.Ordinal) || string.Equals(arg, "--strict-not-found", StringComparison.Ordinal)); + RemoveOptionWithValue(stripped, "--snippet-lines"); if (command == "impact") { stripped.Add("--limit"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index c9618da46..96ebed022 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -414,6 +414,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r FocusColumn = focusColumn, FocusLength = focusLength, SnippetLines = snippetLines, + SnippetLinesExplicit = snippetLinesExplicit, SnippetFocus = snippetFocus, MaxLineWidth = maxLineWidth, PathPatterns = pathPatterns, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 8f1e946c9..902562048 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -458,7 +458,7 @@ private static bool TryParseGraphCommandOptions( } if (options.ParseError == null - && !TryValidateGraphSnippetLinesOption(command, cmdArgs, options)) + && !TryValidateGraphSnippetLinesOption(command, options)) { exitCode = CommandExitCodes.UsageError; return false; @@ -470,10 +470,9 @@ private static bool TryParseGraphCommandOptions( private static bool TryValidateGraphSnippetLinesOption( string command, - string[] cmdArgs, QueryCommandOptions options) { - if (!HasOption(cmdArgs, "--snippet-lines") || options.SnippetLines == 0) + if (!options.SnippetLinesExplicit || options.SnippetLines == 0) return true; if (!options.IncludeBody) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 85b467fb0..8a762f4e1 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -113,6 +113,7 @@ public sealed class QueryCommandOptions public int? FocusColumn { get; init; } public int FocusLength { get; init; } = 1; public int SnippetLines { get; init; } = SearchSnippetFormatter.DefaultSnippetLines; + internal bool SnippetLinesExplicit { get; init; } public SearchSnippetFocusMode SnippetFocus { get; init; } = SearchSnippetFocusMode.Quality; public int MaxLineWidth { get; init; } = LineWidthFormatter.DefaultMaxLineWidth; public List PathPatterns { get; init; } = []; diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs new file mode 100644 index 000000000..ebd7553e3 --- /dev/null +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class JsonEnvelopeWrapperIssue4882Tests +{ + private readonly JsonSerializerOptions _jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + + [Fact] + public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_body_snippet_4882"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Session.cs", + "csharp", + """ + class Session + { + int TargetA() => 1; + int TargetB() => 2; + int Caller() + { + return TargetA() + TargetB(); + } + int Other() + { + return TargetA(); + } + } + """); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var select = db.Connection.CreateCommand(); + select.CommandText = "SELECT id FROM files WHERE path = 'src/Session.cs'"; + var fileId = Convert.ToInt32(select.ExecuteScalar()); + var writer = new DbWriter(db.Connection); + writer.InsertReferences([ + new ReferenceRecord + { + FileId = fileId, + SymbolName = "TargetA", + ReferenceKind = "call", + Line = 7, + Column = 16, + Context = " return TargetA() + TargetB();", + ContainerKind = "function", + ContainerName = "Caller", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "TargetB", + ReferenceKind = "call", + Line = 7, + Column = 28, + Context = " return TargetA() + TargetB();", + ContainerKind = "function", + ContainerName = "Caller", + }, + new ReferenceRecord + { + FileId = fileId, + SymbolName = "TargetA", + ReferenceKind = "call", + Line = 11, + Column = 16, + Context = " return TargetA();", + ContainerKind = "function", + ContainerName = "Other", + }, + ]); + writer.MarkGraphReady(); + writer.MarkFoldReady(); + } + + foreach (var (command, query) in new[] + { + ("references", "TargetA"), + ("callers", "TargetA"), + ("callees", "Caller"), + }) + { + var firstArgs = new[] + { + command, query, "--db", dbPath, "--json", "--body", "--snippet-lines", "3", + "--fields", "path,line,body_content", "--limit", "1", "--max-json-bytes", "8192", + "--exact", + }; + var (firstExitCode, firstStdout, firstStderr) = CaptureConsole( + () => ProgramRunner.Run(firstArgs, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + using var firstDocument = JsonDocument.Parse(firstStdout); + var firstMetadata = firstDocument.RootElement.GetProperty("metadata"); + Assert.True(firstMetadata.TryGetProperty("total_count_authoritative", out _)); + Assert.Equal(2, firstMetadata.GetProperty("total_count").GetInt32()); + Assert.True(firstMetadata.GetProperty("has_more").GetBoolean()); + var cursor = Assert.IsType(firstMetadata.GetProperty("next_cursor").GetString()); + Assert.False(string.IsNullOrWhiteSpace( + firstDocument.RootElement.GetProperty("results")[0].GetProperty("body_content").GetString())); + + var secondArgs = firstArgs.Concat(["--cursor", cursor]).ToArray(); + var (secondExitCode, secondStdout, secondStderr) = CaptureConsole( + () => ProgramRunner.Run(secondArgs, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, secondExitCode); + Assert.Equal(string.Empty, secondStderr); + using var secondDocument = JsonDocument.Parse(secondStdout); + var secondMetadata = secondDocument.RootElement.GetProperty("metadata"); + Assert.Equal(1, secondMetadata.GetProperty("cursor_offset").GetInt32()); + Assert.Equal(2, secondMetadata.GetProperty("total_count").GetInt32()); + Assert.False(secondMetadata.GetProperty("has_more").GetBoolean()); + Assert.Equal(JsonValueKind.Null, secondMetadata.GetProperty("next_cursor").ValueKind); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) + { + using var capture = ConsoleCapture.Start(captureOut: true, captureError: true); + var exitCode = action(); + return (exitCode, capture.Out!.ToString()!, capture.Error!.ToString()!); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs index eb2613626..0a7861b67 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs @@ -708,6 +708,41 @@ public void GraphCommands_SnippetLinesAboveMaximumKeepRangeError_Issue4882() } } + [Fact] + public void GraphCommands_OptionLikeVerbatimQueriesAreNotSnippetOptions_Issue4882() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_graph_verbatim_snippet_query_4882"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + MarkGraphAndFoldReady(dbPath); + var queryForms = new[] + { + new[] { "--db", dbPath, "--json", "--", "--snippet-lines" }, + new[] { "--db", dbPath, "--json", "--", "--snippet-lines=3" }, + new[] { "--db", dbPath, "--json", "--query", "--snippet-lines" }, + }; + + foreach (var command in new[] { "references", "callers", "callees" }) + { + foreach (var args in queryForms) + { + var (exitCode, stdout, stderr) = CaptureConsole( + () => RunGraphCommand(command, args, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + Assert.Equal(0, document.RootElement.GetProperty("count").GetInt32()); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + From 304ebf8c7f50f2d8fbbdd44c3359a1ab2f165489 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 22:34:38 +0900 Subject: [PATCH 3/3] Validate bounded graph snippets before replay (#4882) --- TESTING_GUIDE.md | 4 +- .../Cli/JsonEnvelopeWrapper.Bounded.cs | 42 +++- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 19 +- .../JsonEnvelopeWrapperIssue4882Tests.cs | 204 ++++++++++++------ 4 files changed, 199 insertions(+), 70 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 79a52ad07..2fcddcd46 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -184,7 +184,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. - Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. + Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. @@ -1117,7 +1117,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 - graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。 + graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index 79beb7d53..f0f3cd9ba 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -204,6 +204,11 @@ private static int RunBoundedResponse( CommandExitCodes.UsageError, controls.MaxJsonBytes); } + var bodyProjected = HasExplicitBodyProjection(controls.Fields); + var bodyOutputHidden = !bodyProjected + && (controls.Compact || controls.Fields is { Count: > 0 }); + if (!QueryCommandRunner.TryValidateBoundedGraphSnippetLinesOption(command, args, bodyOutputHidden)) + return CommandExitCodes.UsageError; if (HasArgument(args, "--count") || command == "find" && IsFindCountResponseRequest(args)) return WriteBoundedResponseUsageError("Bounded response controls cannot be combined with --count.", "Run --count --json separately for a count-only response, or remove --count to page projected rows."); @@ -314,7 +319,9 @@ private static int RunBoundedResponse( extraction.PrimaryCollection)) .ToList(); - var count = executionContext?.ReportedTotalCount is { } reportedTotalCount + var count = exitCode == CommandExitCodes.UsageError + ? new ResponseCount(controls.Offset + pageItems.Count, false) + : executionContext?.ReportedTotalCount is { } reportedTotalCount ? new ResponseCount( reportedTotalCount, executionContext.ReportedTotalCountAuthoritative) @@ -1167,7 +1174,7 @@ private static string[] PrepareCountArgs(string command, string[] args) stripped.RemoveAll(arg => string.Equals(arg, "--body", StringComparison.Ordinal) || string.Equals(arg, "--summary-only", StringComparison.Ordinal) || string.Equals(arg, "--strict-not-found", StringComparison.Ordinal)); - RemoveOptionWithValue(stripped, "--snippet-lines"); + RemoveParsedGraphSnippetLinesOption(stripped); if (command == "impact") { stripped.Add("--limit"); @@ -1178,6 +1185,37 @@ private static string[] PrepareCountArgs(string command, string[] args) return [.. stripped]; } + private static void RemoveParsedGraphSnippetLinesOption(List args) + { + for (var i = 0; i < args.Count;) + { + var arg = args[i]; + if (string.Equals(arg, "--", StringComparison.Ordinal)) + { + i += i + 1 < args.Count ? 2 : 1; + continue; + } + if (arg is "--query" or "--db" or "--path") + { + i += i + 1 < args.Count ? 2 : 1; + continue; + } + if (arg.StartsWith("--snippet-lines=", StringComparison.Ordinal)) + { + args.RemoveAt(i); + continue; + } + if (string.Equals(arg, "--snippet-lines", StringComparison.Ordinal)) + { + args.RemoveAt(i); + if (i < args.Count) + args.RemoveAt(i); + continue; + } + i++; + } + } + private static List StripResponseOptions(string[] args, bool stripLimit) { var stripped = new List(args.Length); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 902562048..28da1fb6e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -468,9 +468,23 @@ private static bool TryParseGraphCommandOptions( return true; } + internal static bool TryValidateBoundedGraphSnippetLinesOption( + string command, + string[] args, + bool bodyOutputHidden) + { + if (command is not ("references" or "callers" or "callees")) + return true; + + var options = ParseArgs(args, jsonDefault: false, allowNamedQuery: true); + return options.ParseError != null + || TryValidateGraphSnippetLinesOption(command, options, bodyOutputHidden); + } + private static bool TryValidateGraphSnippetLinesOption( string command, - QueryCommandOptions options) + QueryCommandOptions options, + bool bodyOutputHidden = false) { if (!options.SnippetLinesExplicit || options.SnippetLines == 0) return true; @@ -485,7 +499,8 @@ private static bool TryValidateGraphSnippetLinesOption( return false; } - if (options.CountOnly + if (bodyOutputHidden + || options.CountOnly || options.OutputFormat is not (OutputFormatText or OutputFormatJson)) { CommandErrorWriter.Write( diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs index ebd7553e3..b8c2e6273 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs @@ -16,70 +16,7 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_body_snippet_4882"); try { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/Session.cs", - "csharp", - """ - class Session - { - int TargetA() => 1; - int TargetB() => 2; - int Caller() - { - return TargetA() + TargetB(); - } - int Other() - { - return TargetA(); - } - } - """); - using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) - { - using var select = db.Connection.CreateCommand(); - select.CommandText = "SELECT id FROM files WHERE path = 'src/Session.cs'"; - var fileId = Convert.ToInt32(select.ExecuteScalar()); - var writer = new DbWriter(db.Connection); - writer.InsertReferences([ - new ReferenceRecord - { - FileId = fileId, - SymbolName = "TargetA", - ReferenceKind = "call", - Line = 7, - Column = 16, - Context = " return TargetA() + TargetB();", - ContainerKind = "function", - ContainerName = "Caller", - }, - new ReferenceRecord - { - FileId = fileId, - SymbolName = "TargetB", - ReferenceKind = "call", - Line = 7, - Column = 28, - Context = " return TargetA() + TargetB();", - ContainerKind = "function", - ContainerName = "Caller", - }, - new ReferenceRecord - { - FileId = fileId, - SymbolName = "TargetA", - ReferenceKind = "call", - Line = 11, - Column = 16, - Context = " return TargetA();", - ContainerKind = "function", - ContainerName = "Other", - }, - ]); - writer.MarkGraphReady(); - writer.MarkFoldReady(); - } + var dbPath = CreateGraphFixture(projectRoot); foreach (var (command, query) in new[] { @@ -128,6 +65,145 @@ int Other() } } + [Fact] + public void BoundedGraphCountReplay_PreservesVerbatimSnippetLikeQueries_Issue4882() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_verbatim_snippet_query_4882"); + try + { + var dbPath = CreateGraphFixture(projectRoot); + foreach (var command in new[] { "references", "callers", "callees" }) + { + var queryForms = new[] + { + new[] { "--query", "--snippet-lines" }, + new[] { "--", "--snippet-lines" }, + new[] { "--query=--snippet-lines" }, + }; + foreach (var queryForm in queryForms) + { + var args = new[] { command, "--db", dbPath, "--json", "--fields", "path,line", "--limit", "1", "--max-json-bytes", "8192" } + .Concat(queryForm) + .ToArray(); + var (exitCode, stdout, stderr) = CaptureConsole( + () => ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var metadata = document.RootElement.GetProperty("metadata"); + Assert.Equal("--snippet-lines", metadata.GetProperty("query_normalized").GetString()); + Assert.Equal(2, metadata.GetProperty("total_count").GetInt32()); + Assert.True(metadata.GetProperty("has_more").GetBoolean()); + Assert.False(string.IsNullOrWhiteSpace(metadata.GetProperty("next_cursor").GetString())); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void CompactGraphSnippetValidation_UsesOriginalArgsBeforeDatabase_Issue4882() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_compact_preflight_4882"); + try + { + var missingDbPath = Path.Combine(projectRoot, "missing.db"); + foreach (var command in new[] { "references", "callers", "callees" }) + { + foreach (var compactArgs in new[] + { + new[] { "--compact" }, + new[] { "--format", "compact" }, + }) + { + var args = new[] + { + command, "Target", "--db", missingDbPath, "--body", "--snippet-lines", "3", + } + .Concat(compactArgs) + .ToArray(); + var (exitCode, stdout, stderr) = CaptureConsole( + () => ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--snippet-lines with --body requires text or JSON result output", stderr); + Assert.DoesNotContain("DB_NOT_FOUND", stderr, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("database", stderr, StringComparison.OrdinalIgnoreCase); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static string CreateGraphFixture(string projectRoot) + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Session.cs", + "csharp", + """ + class Session + { + int TargetA() => 1; + int TargetB() => 2; + int Caller() + { + return TargetA() + TargetB(); + } + int Other() + { + return TargetA(); + } + int LiteralCallerOne() => 1; + int LiteralCallerTwo() => 2; + } + """); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var select = db.Connection.CreateCommand(); + select.CommandText = "SELECT id FROM files WHERE path = 'src/Session.cs'"; + var fileId = Convert.ToInt32(select.ExecuteScalar()); + var writer = new DbWriter(db.Connection); + writer.InsertReferences([ + CreateReference(fileId, "TargetA", 7, 16, "Caller"), + CreateReference(fileId, "TargetB", 7, 28, "Caller"), + CreateReference(fileId, "TargetA", 11, 16, "Other"), + CreateReference(fileId, "--snippet-lines", 13, 35, "LiteralCallerOne"), + CreateReference(fileId, "--snippet-lines", 14, 35, "LiteralCallerTwo"), + CreateReference(fileId, "LiteralTargetOne", 7, 16, "--snippet-lines"), + CreateReference(fileId, "LiteralTargetTwo", 7, 28, "--snippet-lines"), + ]); + writer.MarkGraphReady(); + writer.MarkFoldReady(); + return dbPath; + } + + private static ReferenceRecord CreateReference( + int fileId, + string symbolName, + int line, + int column, + string containerName) + => new() + { + FileId = fileId, + SymbolName = symbolName, + ReferenceKind = "call", + Line = line, + Column = column, + Context = $" return {symbolName}();", + ContainerKind = "function", + ContainerName = containerName, + }; + private static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) { using var capture = ConsoleCapture.Start(captureOut: true, captureError: true);