diff --git a/USER_GUIDE.md b/USER_GUIDE.md index df033bf12..f423fc28b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -428,6 +428,13 @@ source line hits the body byte cap, continuation still advances to the following source line because body paging is line-based. `inspect --json` also includes `body_mode` metadata so clients can see whether body content was requested, whether it is present, and which follow-up flags to use. + +For `references`, `callers`, and `callees`, `--body` and `--snippet-lines` +describe the requested body work independently of `--fields`. A projection that +omits every `body_*` field is valid and suppresses body materialization, while +`--fields all` or explicit body fields keep the selected body content, range, +truncation, and recovery metadata. Omitting `--body` still makes an +explicit `--snippet-lines` a usage error. Count-only JSON (`--count --json` or `--format count` where supported) is a single object with `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags @@ -4002,6 +4009,13 @@ top-level group を選択します。`--outline-only` は も返します。`inspect --json` には `body_mode` metadata も含まれるため、body content が要求済みか、存在するか、次に使う flag が何かを client 側で判断できます。 + +`references`、`callers`、`callees` では、`--body` と `--snippet-lines` が表す body +取得意図は `--fields` から独立しています。すべての `body_*` field を省く投影も有効で、 +その場合は body の materialization を省略します。`--fields all` または明示的な body field +を指定した場合は、選択した body content、範囲、truncation、recovery の metadata を +維持します。`--body` を省略したまま `--snippet-lines` を明示すると、従来どおり usage error +になります。 count-only JSON(対応 command の `--count --json` または `--format count`)は、 `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、 `indexed_at`、`freshness_available`)、trust flag の `degraded` / diff --git a/changelog.d/unreleased/5094.fixed.md b/changelog.d/unreleased/5094.fixed.md new file mode 100644 index 000000000..a06d04495 --- /dev/null +++ b/changelog.d/unreleased/5094.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5094 +affected: + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - src/CodeIndex/Cli/QueryCommandRunner.Graph.cs + - USER_GUIDE.md +--- + +## English + +- **Projected graph JSON now validates `--body` from parsed intent (#5094)** — `references`, `callers`, and `callees` no longer report that `--snippet-lines` lacks `--body` when `--fields` omits body fields. Non-body projections also skip body materialization, while explicit body fields and `all` retain the selected body, truncation, and recovery metadata. + +## 日本語 + +- **graph JSON の投影時に parsed intent から `--body` を検証するようになりました (#5094)** — `references`、`callers`、`callees` で `--fields` が body field を省いても、`--snippet-lines` に `--body` が無いという誤ったエラーを返さなくなりました。body を含まない投影では body の materialization も省略し、明示的な body field と `all` では選択した body、truncation、recovery の metadata を維持します。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index 27c16fd07..f89dee75b 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -256,7 +256,8 @@ private static int RunBoundedResponse( } var bodyProjected = HasExplicitBodyProjection(controls.Fields); var bodyOutputHidden = !bodyProjected - && (controls.Compact || controls.Fields is { Count: > 0 }); + && controls.Compact + && controls.Fields is not { Count: > 0 }; if (!QueryCommandRunner.TryValidateBoundedGraphSnippetLinesOption(command, args, bodyOutputHidden)) return CommandExitCodes.UsageError; if (HasArgument(args, "--count") @@ -1549,9 +1550,10 @@ private static bool ReadOptionalBool(JsonObject obj, string propertyName, bool d private static string[] PrepareBoundedInnerArgs(string command, string[] args, BoundedResponseControls controls) { var stripped = StripResponseOptions(command, args, stripLimit: PageableResponseCommands.Contains(command)); - var bodyRequested = HasExplicitBodyProjection(controls.Fields); - if (command != "outline" - && !bodyRequested + var bodyProjected = HasExplicitBodyProjection(controls.Fields); + var bodyOptionRequested = args.Any(arg => string.Equals(arg, "--body", StringComparison.Ordinal)); + if (command is not ("outline" or "references" or "callers" or "callees") + && !bodyProjected && (controls.Compact || controls.Fields is { Count: > 0 })) { stripped.RemoveAll(arg => string.Equals(arg, "--body", StringComparison.Ordinal)); @@ -1563,7 +1565,11 @@ private static string[] PrepareBoundedInnerArgs(string command, string[] args, B additions.Add(controls.PageLimit.ToString(CultureInfo.InvariantCulture)); } if (controls.Compact - && (command == "map" || LegacyLocationCompactCommands.Contains(command) && !bodyRequested)) + && (command == "map" + || LegacyLocationCompactCommands.Contains(command) + && !bodyProjected + && (command is not ("references" or "callers" or "callees") + || !bodyOptionRequested))) { additions.Add("--format"); additions.Add("compact"); @@ -2349,6 +2355,20 @@ internal static int GetBoundedResponseOffset(string command) : 0; } + internal static bool ShouldMaterializeBody(string command) + { + var execution = BoundedExecution.Value; + if (execution is null + || !string.Equals(execution.Command, CanonicalizeCommandName(command), StringComparison.Ordinal)) + { + return true; + } + + return execution.Fields is { Count: > 0 } + ? HasExplicitBodyProjection(execution.Fields) + : !execution.Compact; + } + internal static (string? Path, int? Line, int? FileOrdinal, int? MatchOrdinal, int? ByteOffset) GetBoundedFindResume() { var execution = BoundedExecution.Value; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 35c8d293c..a38c522b6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -64,7 +64,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti } var results = reader.SearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("references"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls); - if (options.IncludeBody) + if (options.IncludeBody && JsonEnvelopeWrapper.ShouldMaterializeBody("references")) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); @@ -203,7 +203,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions } var results = reader.GetCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls, includeMemberReads: options.IncludeMemberReads); - if (options.IncludeBody) + if (options.IncludeBody && JsonEnvelopeWrapper.ShouldMaterializeBody("callers")) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); @@ -350,7 +350,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions } var results = reader.GetCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees"), includeQualifiedCommonCalls: options.IncludeQualifiedCommonCalls, includeMemberReads: options.IncludeMemberReads); - if (options.IncludeBody) + if (options.IncludeBody && JsonEnvelopeWrapper.ShouldMaterializeBody("callees")) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs index b8c2e6273..a0e59c139 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4882Tests.cs @@ -21,6 +21,7 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() foreach (var (command, query) in new[] { ("references", "TargetA"), + ("refs", "TargetA"), ("callers", "TargetA"), ("callees", "Caller"), }) @@ -28,7 +29,8 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() var firstArgs = new[] { command, query, "--db", dbPath, "--json", "--body", "--snippet-lines", "3", - "--fields", "path,line,body_content", "--limit", "1", "--max-json-bytes", "8192", + "--fields", "path,line,body_content,body_start_line,body_end_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", + "--limit", "1", "--max-json-bytes", "16384", "--exact", }; var (firstExitCode, firstStdout, firstStderr) = CaptureConsole( @@ -42,8 +44,26 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() 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 firstResult = firstDocument.RootElement.GetProperty("results")[0]; + Assert.False(string.IsNullOrWhiteSpace(firstResult.GetProperty("body_content").GetString())); + foreach (var field in new[] + { + "body_start_line", + "body_end_line", + "body_requested_start_line", + "body_requested_end_line", + "body_effective_start_line", + "body_effective_end_line", + }) + { + Assert.True(firstResult.TryGetProperty(field, out _), field); + } + if (firstResult.TryGetProperty("body_content_truncated", out var bodyContentTruncated)) + { + Assert.True(bodyContentTruncated.GetBoolean()); + Assert.True(firstResult.TryGetProperty("body_content_truncation_reasons", out _)); + Assert.True(firstResult.TryGetProperty("body_content_recovery", out _)); + } var secondArgs = firstArgs.Concat(["--cursor", cursor]).ToArray(); var (secondExitCode, secondStdout, secondStderr) = CaptureConsole( @@ -65,6 +85,218 @@ public void GraphBodySnippetProjection_PreservesCountAndPagination_Issue4882() } } + [Fact] + public void GraphBodyIntent_IsIndependentFromBoundedProjection_Issue5094() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_body_intent_5094"); + try + { + var dbPath = CreateGraphFixture(projectRoot); + var projections = new (string? Fields, bool ExpectBody)[] + { + ("path,line", false), + ("file,line", false), + ("body_content,body_content_truncated,body_content_recovery", true), + ("all", true), + }; + + foreach (var (command, query) in new[] + { + ("references", "TargetA"), + ("refs", "TargetA"), + ("callers", "TargetA"), + ("callees", "Caller"), + }) + { + var unprojectedArgs = new[] + { + command, query, "--db", dbPath, "--json", "--body", "--snippet-lines", "3", + "--limit", "1", "--exact-name", + }; + var (unprojectedExitCode, unprojectedStdout, unprojectedStderr) = CaptureConsole( + () => ProgramRunner.Run(unprojectedArgs, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, unprojectedExitCode); + Assert.Equal(string.Empty, unprojectedStderr); + using (var unprojectedDocument = JsonDocument.Parse(unprojectedStdout)) + { + Assert.False(string.IsNullOrWhiteSpace( + unprojectedDocument.RootElement.GetProperty("body_content").GetString())); + } + + foreach (var projection in projections) + { + foreach (var compact in new[] { false, true }) + { + var args = new List + { + command, query, "--db", dbPath, "--json", "--body", "--snippet-lines", "3", + "--limit", "1", "--max-json-bytes", "32768", "--exact-name", + }; + if (compact) + args.Add("--compact"); + if (projection.Fields is not null) + { + args.Add("--fields"); + args.Add(projection.Fields); + } + + 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 result = document.RootElement.GetProperty("results")[0]; + Assert.Equal(projection.ExpectBody, result.TryGetProperty("body_content", out _)); + if (!projection.ExpectBody) + { + Assert.DoesNotContain( + result.EnumerateObject().Select(property => property.Name), + propertyName => propertyName.StartsWith("body_", StringComparison.Ordinal)); + } + } + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void GraphExplicitProjection_PreservesCompactEnvelopeAndEmptyResults_Issue5094() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_compact_projection_5094"); + try + { + var dbPath = CreateGraphFixture(projectRoot); + foreach (var (command, query) in new[] + { + ("references", "TargetA"), + ("refs", "TargetA"), + ("callers", "TargetA"), + ("callees", "Caller"), + }) + { + foreach (var compactArgs in new[] + { + new[] { "--compact" }, + new[] { "--format", "compact" }, + }) + { + var commonArgs = new[] + { + "--db", dbPath, "--json", "--fields", "path,line", "--limit", "1", + "--max-json-bytes", "8192", "--exact-name", + } + .Concat(compactArgs) + .ToArray(); + var (matchExitCode, matchStdout, matchStderr) = CaptureConsole( + () => ProgramRunner.Run( + [command, query, .. commonArgs], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, matchExitCode); + Assert.Equal(string.Empty, matchStderr); + using (var matchDocument = JsonDocument.Parse(matchStdout)) + { + Assert.Equal("compact", matchDocument.RootElement.GetProperty("format").GetString()); + Assert.Single(matchDocument.RootElement.GetProperty("results").EnumerateArray()); + } + + // The pre-existing zero-match callees contract is tracked separately by #5128. + if (command == "callees") + continue; + + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole( + () => ProgramRunner.Run( + [command, "DefinitelyNoSuchSymbol_5094", .. commonArgs], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + using var emptyDocument = JsonDocument.Parse(emptyStdout); + Assert.True(emptyDocument.RootElement.TryGetProperty("format", out var emptyFormat), emptyStdout); + Assert.Equal("compact", emptyFormat.GetString()); + Assert.True(emptyDocument.RootElement.TryGetProperty("results", out var emptyResults), emptyStdout); + Assert.Empty(emptyResults.EnumerateArray()); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void GraphBodyIntentValidation_PrecedesProjectionAndDatabaseAccess_Issue5094() + { + var projectRoot = TestProjectHelper.CreateTempProject("bounded_graph_body_validation_5094"); + try + { + var missingDbPath = Path.Combine(projectRoot, "missing.db"); + foreach (var command in new[] { "references", "refs", "callers", "callees" }) + { + foreach (var (bodyArgs, expectedMessage, unexpectedMessage) in new[] + { + (new[] { "--snippet-lines", "3" }, "--snippet-lines requires --body", "database"), + (new[] { "--body", "--snippet-lines", "21" }, "--snippet-lines must be less than or equal to 20", "--snippet-lines requires --body"), + }) + { + var args = new[] + { + command, "Target", "--db", missingDbPath, "--json", "--fields", "path,line", + "--max-json-bytes", "8192", + } + .Concat(bodyArgs) + .ToArray(); + var (exitCode, stdout, stderr) = CaptureConsole( + () => ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + var diagnostic = stdout + stderr; + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains(CommandErrorCodes.UsageError, diagnostic); + Assert.Contains(expectedMessage, diagnostic); + Assert.DoesNotContain(unexpectedMessage, diagnostic, StringComparison.OrdinalIgnoreCase); + } + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void GraphFieldDiscovery_RemainsIndependentFromBodyIntent_Issue5094() + { + foreach (var command in new[] { "references", "refs", "callers", "callees" }) + { + var args = new[] + { + command, "--json", "--body", "--snippet-lines", "3", "--fields", "list", + }; + 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 validFields = document.RootElement.GetProperty("valid_fields") + .EnumerateArray() + .Select(field => field.GetString()) + .ToArray(); + Assert.Contains("body_content", validFields); + Assert.Contains("body_content_truncated", validFields); + Assert.Contains("body_content_recovery", validFields); + } + } + [Fact] public void BoundedGraphCountReplay_PreservesVerbatimSnippetLikeQueries_Issue4882() {