From c0a8a2cd972f2fd4fbac0f8d317f3eb5779d5af5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:46:32 +0900 Subject: [PATCH 1/5] Add inspect body pagination for #3394 --- USER_GUIDE.md | 14 +++- changelog.d/unreleased/3394.added.md | 21 ++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 49 +++++++++++++- src/CodeIndex/Database/DbSymbolReader.cs | 29 +++++++-- src/CodeIndex/Models/QueryResults.cs | 6 ++ .../QueryCommandRunnerInspectTests.cs | 64 +++++++++++++++++++ 8 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/3394.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..6bf79e2aaf 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -312,7 +312,9 @@ For AI-oriented bounded payloads, `map`, `inspect`, and `outline` accept For narrower `inspect` evidence, `--fields ` implies JSON and selects top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`; `--body-only` is shorthand for `--body --fields -definitions`. +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 +`--body-start`; use `--body-lines` to choose the page size. ```bash cdidx search authenticate --json # ndjson stream, one result per line @@ -320,6 +322,7 @@ cdidx search authenticate --json=array # single JSON array cdidx inspect QueryCommandRunner --json --pretty cdidx map --compact # capped JSON with truncation metadata cdidx inspect Compute --body-only # definitions with body_content only +cdidx inspect Compute --body --body-start 40 --body-lines 40 ``` For `cdidx find --count --json`, `files` is the canonical matched-file count. @@ -1219,6 +1222,8 @@ same source location. | `--compact` | `map`, `inspect`, `outline` | Emit AI-oriented compact JSON with capped list sections and `truncation.sections.*` metadata. The default cap is 5 unless `--limit` / `--top` is supplied. | | `--fields ` | `inspect` | Select top-level inspect JSON groups: `file`, `workspace`, `graph`, `definitions`, `body`, `nearby_symbols`, `references`, `callers`, `callees`, or `all`. `body` includes definition bodies and maps to `definitions`. | | `--body-only` | `inspect` | Shorthand for `--body --fields definitions`, useful when large audits need implementation text without graph context. | +| `--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. | +| `--body-lines ` | `inspect` | Return at most this many definition body lines for `--body`, `--body-only`, or `--fields body`; maximum 1000. | | `--status ` | `suggestions` | Filter local suggestion history by GitHub submission state. | | `--language ` / `--lang ` | `suggestions` | Filter local suggestion history by recorded target language. | | `--category ` | `suggestions` | Filter local suggestion history by suggestion category. | @@ -2611,7 +2616,9 @@ AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outli `inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 `definitions`、`file`、`graph`、`references`、`callers`、`callees` などの top-level group を選択します。`--body-only` は `--body --fields definitions` の -shorthand です。 +shorthand です。definition body が返却 slice より長い場合は +`body_content_next_start_line` が次に `--body-start` へ渡す source line を示します。 +`--body-lines` で page size を指定できます。 ```bash cdidx search authenticate --json # ndjson stream、1 行 1 result @@ -2619,6 +2626,7 @@ cdidx search authenticate --json=array # 単一 JSON array cdidx inspect QueryCommandRunner --json --pretty cdidx map --compact # truncation metadata 付きの cap 済み JSON cdidx inspect Compute --body-only # body_content 付き definitions のみ +cdidx inspect Compute --body --body-start 40 --body-lines 40 ``` ## Editor / index portability @@ -3517,6 +3525,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--compact` | `map`、`inspect`、`outline` | list section を cap した AI 向け compact JSON を出力し、`truncation.sections.*` metadata を含める。既定 cap は 5 件で、`--limit` / `--top` 指定時はその値を使う。 | | `--fields ` | `inspect` | inspect JSON の top-level group を選択。`file`、`workspace`、`graph`、`definitions`、`body`、`nearby_symbols`、`references`、`callers`、`callees`、`all` を指定できる。`body` は definition body を含め、`definitions` に対応する。 | | `--body-only` | `inspect` | `--body --fields definitions` の shorthand。大規模 audit で graph context なしに実装本文だけが必要な場合に使う。 | +| `--body-start ` | `inspect` | symbol body 内の 1-based source line から definition body slice を返す。長い body の page 送りでは JSON の `body_content_next_start_line` を次の値として渡す。 | +| `--body-lines ` | `inspect` | `--body`、`--body-only`、`--fields body` で返す definition body 行数の上限。最大 1000。 | | `--status ` | `suggestions` | ローカル提案履歴を GitHub 送信状態で絞り込みます。 | | `--language ` / `--lang ` | `suggestions` | ローカル提案履歴を記録済み対象言語で絞り込みます。 | | `--category ` | `suggestions` | ローカル提案履歴を提案カテゴリで絞り込みます。 | diff --git a/changelog.d/unreleased/3394.added.md b/changelog.d/unreleased/3394.added.md new file mode 100644 index 0000000000..038873bf7e --- /dev/null +++ b/changelog.d/unreleased/3394.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3394 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - USER_GUIDE.md +--- + +## English + +- **Inspect can page long definition bodies (#3394)** — `inspect --body` now accepts `--body-start` and `--body-lines`, and JSON definition rows include the returned body slice range plus `body_content_next_start_line` for fetching the next page. + +## 日本語 + +- **長い定義 body を `inspect` で page 取得できるようになりました (#3394)** — `inspect --body` は `--body-start` と `--body-lines` を受け付け、JSON の definition 行には返却した body slice 範囲と次ページ用の `body_content_next_start_line` が含まれるようになりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 9593377625..89e95fbac0 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -282,6 +282,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--category", ValuePlaceholder = "", Description = "Suggestions: filter by category", Commands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "", Description = "Suggestions: filter by agent", Commands = Set("suggestions") }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, + new() { Name = "--body-start", ValuePlaceholder = "", Description = "Inspect: start definition body slice at this 1-based source line", Commands = Set(InspectFieldCommands) }, + new() { Name = "--body-lines", ValuePlaceholder = "", Description = "Inspect: return at most this many definition body lines", Commands = Set(InspectFieldCommands) }, new() { Name = "--fields", ValuePlaceholder = "", Description = "Inspect: select top-level JSON evidence groups", Commands = Set(InspectFieldCommands) }, new() { Name = "--body-only", Description = "Inspect: JSON shorthand for --body --fields definitions", Commands = Set(InspectFieldCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ba31188742..c05350ddf8 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -98,7 +98,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("find", "cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), + ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), ("workspace", "cdidx workspace [name] [--json]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 3b0a8f6536..f37a948816 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -117,6 +117,8 @@ public static class QueryCommandRunner "--end", "--before", "--after", + "--body-start", + "--body-lines", "--name", "--snippet-lines", "--snippet-focus", @@ -3419,7 +3421,18 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions { var compactLimit = GetCompactSectionLimit(options); var inspectLimit = options.Compact ? GetCompactSourceLimit(compactLimit) : options.Limit; - var analysis = reader.AnalyzeSymbol(options.Query, inspectLimit, options.Lang, options.IncludeBody, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth); + var analysis = reader.AnalyzeSymbol( + options.Query, + inspectLimit, + options.Lang, + options.IncludeBody, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + exact, + options.MaxLineWidth, + options.BodyStartLine, + options.BodyLines); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), DbReader.IsSqlLanguage(options.Lang) @@ -6109,6 +6122,8 @@ public static QueryCommandOptions ParseArgs( string? query = null; bool rawFts = false; bool includeBody = false; + int? bodyStartLine = null; + int? bodyLines = null; bool countOnly = false; bool strictNotFound = false; int? startLine = null; @@ -6590,6 +6605,30 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--body": includeBody = true; break; + case "--body-start": + if (!TryReadRawOptionValue(args, ref i, "--body-start", inlineValue, out var bodyStartValue, out var missingBodyStartError)) + AddParseError(missingBodyStartError!); + else if (TryParsePositiveInt(bodyStartValue!, "--body-start", out var parsedBodyStartLine, out var bodyStartError)) + { + WarnIfDuplicateSingleValueOption("--body-start", bodyStartValue!); + bodyStartLine = parsedBodyStartLine; + includeBody = true; + } + else + AddParseError(bodyStartError!); + break; + case "--body-lines": + if (!TryReadRawOptionValue(args, ref i, "--body-lines", inlineValue, out var bodyLinesValue, out var missingBodyLinesError)) + AddParseError(missingBodyLinesError!); + else if (TryParsePositiveInt(bodyLinesValue!, "--body-lines", out var parsedBodyLines, out var bodyLinesError)) + { + WarnIfDuplicateSingleValueOption("--body-lines", bodyLinesValue!); + bodyLines = parsedBodyLines; + includeBody = true; + } + else + AddParseError(bodyLinesError!); + break; case "--count": countOnly = true; break; @@ -7028,6 +7067,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Query = query, RawFts = rawFts, IncludeBody = includeBody, + BodyStartLine = bodyStartLine, + BodyLines = bodyLines, StartLine = startLine, EndLine = endLine, ContextBefore = contextBefore, @@ -9618,6 +9659,8 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--snippet-lines"] = SearchSnippetFormatter.MaxSnippetLines, ["--max-line-width"] = LineWidthFormatter.MaxAllowedLineWidth, ["--slow-query-ms"] = 3_600_000, + ["--body-start"] = 10_000_000, + ["--body-lines"] = DbReader.DefinitionBodyMaxRequestedLines, ["--max-hops"] = 64, ["--depth"] = 64, ["--before"] = 1_000, @@ -9642,6 +9685,8 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--data-dir"] = "pass a directory where cdidx should store `codeindex.db`, e.g. `--data-dir /var/cache/cdidx`.", ["--limit"] = "pass a positive integer, e.g. `--limit 20` (default 20).", ["--top"] = "pass a positive integer, e.g. `--top 20` (alias for `--limit`, default 20).", + ["--body-start"] = "pass a 1-based source line inside the symbol body, e.g. `--body-start 120`.", + ["--body-lines"] = "pass a positive line count for the body slice, e.g. `--body-lines 40`.", ["--lang"] = "pass a language identifier, e.g. `--lang csharp`. Run `cdidx languages` for the supported set.", ["--query"] = "pass a search literal, e.g. `--query \"authenticate\"`. Use the `--query` form when the literal starts with `-`.", ["--recipe"] = "pass a built-in audit recipe name, e.g. `--recipe risky-code`; run `cdidx search --list-recipes` to list available recipes.", @@ -10013,6 +10058,8 @@ public sealed class QueryCommandOptions public string? Query { get; init; } public bool RawFts { get; init; } public bool IncludeBody { get; init; } + public int? BodyStartLine { get; init; } + public int? BodyLines { get; init; } public int? StartLine { get; init; } public int? EndLine { get; init; } public int ContextBefore { get; init; } diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index d3c165d2fe..afa2f21992 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -12,6 +12,7 @@ namespace CodeIndex.Database; public partial class DbReader { internal const int DefinitionBodyMaxLines = 20; + internal const int DefinitionBodyMaxRequestedLines = 1_000; internal const int DefinitionBodyMaxBytes = 16 * 1024; private const string UnusedBucketLikelyPrivate = "likely_unused_private"; @@ -777,7 +778,7 @@ FROM symbols s /// Resolve symbol definitions with reconstructed excerpts. /// シンボル定義を抜粋付きで解決する。 /// - public List GetDefinitions(string query, int limit = 20, string? kind = null, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + public List GetDefinitions(string query, int limit = 20, string? kind = null, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, int? bodyStartLine = null, int? bodyLineCount = null) { lang = DbReader.NormalizeQueryLanguage(lang); var symbols = SearchSymbols(query, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); @@ -790,19 +791,34 @@ public List GetDefinitions(string query, int limit = 20, strin continue; string? bodyContent = null; + int? bodyContentStartLine = null; + int? bodyContentEndLine = null; + int? bodyContentNextStartLine = null; var bodyContentTruncated = false; if (includeBody && symbol.BodyStartLine != null && symbol.BodyEndLine != null) { + var requestedBodyLines = Math.Clamp( + bodyLineCount ?? DefinitionBodyMaxLines, + 1, + DefinitionBodyMaxRequestedLines); + var effectiveBodyStartLine = Math.Clamp( + bodyStartLine ?? symbol.BodyStartLine.Value, + symbol.BodyStartLine.Value, + symbol.BodyEndLine.Value); var cappedBodyEndLine = Math.Min( symbol.BodyEndLine.Value, - symbol.BodyStartLine.Value + DefinitionBodyMaxLines - 1); - var bodyExcerpt = GetExcerpt(symbol.Path, symbol.BodyStartLine.Value, cappedBodyEndLine); + effectiveBodyStartLine + requestedBodyLines - 1); + var bodyExcerpt = GetExcerpt(symbol.Path, effectiveBodyStartLine, cappedBodyEndLine); if (bodyExcerpt != null) { bodyContent = bodyExcerpt.Content; + bodyContentStartLine = bodyExcerpt.StartLine; + bodyContentEndLine = bodyExcerpt.EndLine; bodyContentTruncated = bodyExcerpt.ContentTruncated || cappedBodyEndLine < symbol.BodyEndLine.Value; (bodyContent, var byteTruncated) = ClampDefinitionBodyBytes(bodyContent); bodyContentTruncated |= byteTruncated; + if (cappedBodyEndLine < symbol.BodyEndLine.Value) + bodyContentNextStartLine = cappedBodyEndLine + 1; } } @@ -826,6 +842,9 @@ public List GetDefinitions(string query, int limit = 20, strin Disambiguator = BuildDefinitionDisambiguator(symbol), Content = definitionExcerpt.Content, BodyContent = bodyContent, + BodyContentStartLine = bodyContentStartLine, + BodyContentEndLine = bodyContentEndLine, + BodyContentNextStartLine = bodyContentNextStartLine, BodyContentTruncated = bodyContentTruncated, Complexity = bodyContent != null && !bodyContentTruncated ? SymbolExtractor.EstimateComplexity(bodyContent) @@ -1338,7 +1357,7 @@ FROM symbols s /// Bundle definition, graph, and local file context for one symbol query. /// 単一シンボルクエリ向けに、定義・グラフ・ローカル文脈をまとめて返す。 /// - public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth) + public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) { @@ -1370,7 +1389,7 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? // が同じ WAL snapshot を参照するようにする。 using var txn = _conn.BeginTransaction(deferred: true); var definitionLimit = Math.Min(limit, 5); - var definitions = GetDefinitions(normalizedQuery, definitionLimit, kind: null, lang, includeBody, pathPatterns, excludePathPatterns, excludeTests, since: null, exact); + var definitions = GetDefinitions(normalizedQuery, definitionLimit, kind: null, lang, includeBody, pathPatterns, excludePathPatterns, excludeTests, since: null, exact, bodyStartLine: bodyStartLine, bodyLineCount: bodyLineCount); DefinitionResult? primaryDefinition = definitions .FirstOrDefault(definition => ReferenceExtractor.SupportsLanguage(definition.Lang) == true && !IsCSharpEnumMemberDefinition(definition)) ?? definitions.FirstOrDefault(definition => ReferenceExtractor.SupportsLanguage(definition.Lang) == true) diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 53caa83c84..09ca77662f 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -230,6 +230,12 @@ public class DefinitionResult : SymbolResult public string? Disambiguator { get; set; } public string Content { get; set; } = string.Empty; public string? BodyContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? BodyContentStartLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? BodyContentEndLine { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? BodyContentNextStartLine { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool BodyContentTruncated { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index e62c6ff143..0bf9361ef7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -168,6 +168,21 @@ public void RunInspect_ParseFields_ImplyJsonAndCanonicalizeAliases_Issue3056() Assert.Null(options.ParseError); } + [Fact] + public void RunInspect_ParseBodyRange_ImplyBodyAndValidateValues_Issue3394() + { + var options = QueryCommandRunner.ParseArgs( + ["--body-start", "6", "--body-lines=2"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.True(options.IncludeBody); + Assert.Equal(6, options.BodyStartLine); + Assert.Equal(2, options.BodyLines); + Assert.Null(options.ParseError); + } + [Fact] public void RunInspect_FieldsJson_EmitsOnlySelectedTopLevelGroups_Issue3056() { @@ -258,6 +273,55 @@ public int Compute() } } + [Fact] + public void RunInspect_BodyRangeJson_PagesDefinitionBody_Issue3394() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_range_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public int Compute() + { + var value1 = 1; + var value2 = 2; + var value3 = 3; + return value1 + value2 + value3; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--body", "--body-start", "6", "--body-lines", "2"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var definition = document.RootElement.GetProperty("definitions").EnumerateArray().Single(); + var bodyContent = definition.GetProperty("body_content").GetString(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("var value2 = 2;", bodyContent, StringComparison.Ordinal); + Assert.Contains("var value3 = 3;", bodyContent, StringComparison.Ordinal); + Assert.DoesNotContain("var value1 = 1;", bodyContent, StringComparison.Ordinal); + Assert.DoesNotContain("return value1", bodyContent, StringComparison.Ordinal); + Assert.Equal(6, definition.GetProperty("body_content_start_line").GetInt32()); + Assert.Equal(7, definition.GetProperty("body_content_end_line").GetInt32()); + Assert.Equal(8, definition.GetProperty("body_content_next_start_line").GetInt32()); + Assert.True(definition.GetProperty("body_content_truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunOutline_IndentsByContainerDepth() { From a626124c869a11de0673c103c016b68381ccbafd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:01:31 +0900 Subject: [PATCH 2/5] Clarify inspect body modes for #3441 --- USER_GUIDE.md | 8 +- changelog.d/unreleased/3441.changed.md | 18 ++++ src/CodeIndex/Cli/CliFlagSchema.cs | 4 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 66 +++++++++++++++ .../QueryCommandRunnerInspectTests.cs | 83 +++++++++++++++++++ 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3441.changed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6bf79e2aaf..74a4b9be8b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -314,7 +314,9 @@ top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`; `--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 -`--body-start`; use `--body-lines` to choose the page size. +`--body-start`; use `--body-lines` to choose the page size. `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. ```bash cdidx search authenticate --json # ndjson stream, one result per line @@ -2618,7 +2620,9 @@ AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outli top-level group を選択します。`--body-only` は `--body --fields definitions` の shorthand です。definition body が返却 slice より長い場合は `body_content_next_start_line` が次に `--body-start` へ渡す source line を示します。 -`--body-lines` で page size を指定できます。 +`--body-lines` で page size を指定できます。`inspect --json` には `body_mode` +metadata も含まれるため、body content が要求済みか、存在するか、次に使う flag が何かを +client 側で判断できます。 ```bash cdidx search authenticate --json # ndjson stream、1 行 1 result diff --git a/changelog.d/unreleased/3441.changed.md b/changelog.d/unreleased/3441.changed.md new file mode 100644 index 0000000000..d69e55121e --- /dev/null +++ b/changelog.d/unreleased/3441.changed.md @@ -0,0 +1,18 @@ +--- +category: changed +issues: + - 3441 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs + - USER_GUIDE.md +--- + +## English + +- **Inspect now reports body output mode metadata (#3441)** — `inspect --json` includes a `body_mode` block describing whether body content was requested or present, while human `inspect` output prints a body hint when source bodies are omitted. + +## 日本語 + +- **`inspect` が body 出力 mode metadata を返すようになりました (#3441)** — `inspect --json` は body content が要求済みか、存在するかを示す `body_mode` block を含み、人間向け `inspect` 出力では source body が省略されたときに body hint を表示します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 89e95fbac0..72fcd09cd2 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -281,11 +281,11 @@ private static IReadOnlyList BuildAll() new() { Name = "--status", ValuePlaceholder = "", Description = "Suggestions: filter by suggestion status", Commands = Set("suggestions") }, new() { Name = "--category", ValuePlaceholder = "", Description = "Suggestions: filter by category", Commands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "", Description = "Suggestions: filter by agent", Commands = Set("suggestions") }, - new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, + new() { Name = "--body", Description = "Include definition body snippets in JSON-capable result rows", Commands = Set(BodyCommands) }, new() { Name = "--body-start", ValuePlaceholder = "", Description = "Inspect: start definition body slice at this 1-based source line", Commands = Set(InspectFieldCommands) }, new() { Name = "--body-lines", ValuePlaceholder = "", Description = "Inspect: return at most this many definition body lines", Commands = Set(InspectFieldCommands) }, new() { Name = "--fields", ValuePlaceholder = "", Description = "Inspect: select top-level JSON evidence groups", Commands = Set(InspectFieldCommands) }, - new() { Name = "--body-only", Description = "Inspect: JSON shorthand for --body --fields definitions", Commands = Set(InspectFieldCommands) }, + new() { Name = "--body-only", Description = "Inspect: body-focused JSON shorthand for --body --fields definitions", Commands = Set(InspectFieldCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, new() { Name = "--regex", Description = "Use regular expression matching", Commands = Set("find") }, new() { Name = "--exact-name", Description = "Exact symbol-name equality", Commands = Set(ExactNameCommands), AlsoAcceptedBy = Set("search") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index f37a948816..22d3e5ca1f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3374,6 +3374,70 @@ private static void FilterInspectCompactTruncationSections(JsonObject payload, I private static bool IsInspectListField(string field) => field is "definitions" or "nearby_symbols" or "references" or "callers" or "callees"; + private static void AddInspectBodyModeJsonFields(JsonObject payload, QueryCommandOptions options, SymbolAnalysisResult analysis) + { + var bodyContentPresent = analysis.Definitions.Any(definition => definition.BodyContent != null); + var bodyContentTruncated = analysis.Definitions.Any(definition => definition.BodyContentTruncated); + var nextStartLine = analysis.Definitions + .Where(definition => definition.BodyContentNextStartLine.HasValue) + .Select(definition => definition.BodyContentNextStartLine!.Value) + .DefaultIfEmpty() + .Min(); + + var bodyMode = new JsonObject + { + ["include_body"] = options.IncludeBody, + ["definitions_only"] = IsInspectDefinitionsOnlyMode(options), + ["body_content_present"] = bodyContentPresent, + ["body_content_truncated"] = bodyContentTruncated, + ["default_body_lines"] = DbReader.DefinitionBodyMaxLines, + ["max_body_lines"] = DbReader.DefinitionBodyMaxRequestedLines, + ["hint"] = BuildInspectBodyModeHint(options, bodyContentPresent, bodyContentTruncated), + }; + if (options.BodyStartLine.HasValue) + bodyMode["body_start_line"] = options.BodyStartLine.Value; + if (options.BodyLines.HasValue) + bodyMode["body_lines"] = options.BodyLines.Value; + else if (options.IncludeBody) + bodyMode["body_lines"] = DbReader.DefinitionBodyMaxLines; + if (nextStartLine > 0) + bodyMode["next_body_start_line"] = nextStartLine; + + payload["body_mode"] = bodyMode; + } + + private static void WriteInspectBodyModeHint(SymbolAnalysisResult analysis, QueryCommandOptions options) + { + if (analysis.Definitions.Count == 0) + return; + + var bodyContentPresent = analysis.Definitions.Any(definition => definition.BodyContent != null); + var bodyContentTruncated = analysis.Definitions.Any(definition => definition.BodyContentTruncated); + Console.WriteLine($"Body Hint : {BuildInspectBodyModeHint(options, bodyContentPresent, bodyContentTruncated)}"); + } + + private static bool IsInspectDefinitionsOnlyMode(QueryCommandOptions options) + => options.IncludeBody + && options.InspectFields is { Count: 1 } fields + && string.Equals(fields[0], "definitions", StringComparison.Ordinal); + + private static string BuildInspectBodyModeHint(QueryCommandOptions options, bool bodyContentPresent, bool bodyContentTruncated) + { + if (!options.IncludeBody) + return "Add `--body` for definition body snippets in JSON, or use `--body-only` for body-focused JSON. Page long bodies with `--body-start --body-lines `."; + + if (!options.Json) + return "Body content was requested, but human inspect output stays summary-only; use `--json --fields body` or `--body-only` to show `body_content`."; + + if (bodyContentTruncated) + return "Use each definition's `body_content_next_start_line` with `--body-start ` and optionally `--body-lines ` to fetch the next body slice."; + + if (bodyContentPresent) + return "Body content is present under each definition's `body_content` field."; + + return "No definition body content is available for the matched definitions."; + } + public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var previewOptionError = ValidatePreviewOptions("inspect", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); @@ -3463,6 +3527,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions if (compactTruncation != null) AddCompactJsonFields(payload, compactLimit, compactTruncation); ApplyInspectFieldSelection(payload, options, jsonOptions); + AddInspectBodyModeJsonFields(payload, options, analysis); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -3499,6 +3564,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions } } WriteExactZeroHint(analysis.ExactZeroHint); + WriteInspectBodyModeHint(analysis, options); WriteRepoMapSection("Definitions", analysis.Definitions.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.StartLine}-{item.EndLine}")); WriteRepoMapSection("Nearby symbols", analysis.NearbySymbols.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.StartLine}-{item.EndLine}")); WriteRepoMapSection("References", analysis.References.Select(item => $"{item.Path}:{item.Line}:{item.Column} {item.Context}")); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 0bf9361ef7..45680fb3ac 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -322,6 +322,89 @@ public int Compute() } } + [Fact] + public void RunInspect_JsonWithoutBody_IncludesBodyModeHint_Issue3441() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_mode_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public int Compute() + { + return 42; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var definition = json.GetProperty("definitions").EnumerateArray().Single(); + var bodyMode = json.GetProperty("body_mode"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(JsonValueKind.Null, definition.GetProperty("body_content").ValueKind); + Assert.False(bodyMode.GetProperty("include_body").GetBoolean()); + Assert.False(bodyMode.GetProperty("definitions_only").GetBoolean()); + Assert.False(bodyMode.GetProperty("body_content_present").GetBoolean()); + Assert.Equal(DbReader.DefinitionBodyMaxLines, bodyMode.GetProperty("default_body_lines").GetInt32()); + Assert.Equal(DbReader.DefinitionBodyMaxRequestedLines, bodyMode.GetProperty("max_body_lines").GetInt32()); + Assert.Contains("--body-only", bodyMode.GetProperty("hint").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunInspect_HumanWithoutBody_PrintsBodyModeHint_Issue3441() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_mode_human"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public int Compute() + { + return 42; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("Body Hint", stdout, StringComparison.Ordinal); + Assert.Contains("--body", stdout, StringComparison.Ordinal); + Assert.Contains("--body-only", stdout, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunOutline_IndentsByContainerDepth() { From 995a5ea622c2412579e7c7a0b166f60134c8172f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:23:39 +0900 Subject: [PATCH 3/5] Update inspect usage expectations for #3441 --- changelog.d/unreleased/3441.changed.md | 1 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.d/unreleased/3441.changed.md b/changelog.d/unreleased/3441.changed.md index d69e55121e..654dd9900f 100644 --- a/changelog.d/unreleased/3441.changed.md +++ b/changelog.d/unreleased/3441.changed.md @@ -5,6 +5,7 @@ issues: affected: - src/CodeIndex/Cli/QueryCommandRunner.cs - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs - USER_GUIDE.md --- diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index aecbf9c203..cc1d23a0de 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -118,7 +118,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]", output); Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); From 571fabb908d79e1987e71fa31cdde533b2ca1b52 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:11:48 +0900 Subject: [PATCH 4/5] Fix inspect body byte range metadata for #3394 --- src/CodeIndex/Database/DbSymbolReader.cs | 63 +++++++++++++++++-- .../QueryCommandRunnerInspectTests.cs | 48 ++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index afa2f21992..3d6494d25d 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -815,9 +815,28 @@ public List GetDefinitions(string query, int limit = 20, strin bodyContentStartLine = bodyExcerpt.StartLine; bodyContentEndLine = bodyExcerpt.EndLine; bodyContentTruncated = bodyExcerpt.ContentTruncated || cappedBodyEndLine < symbol.BodyEndLine.Value; - (bodyContent, var byteTruncated) = ClampDefinitionBodyBytes(bodyContent); - bodyContentTruncated |= byteTruncated; - if (cappedBodyEndLine < symbol.BodyEndLine.Value) + var byteClamp = ClampDefinitionBodyBytes(bodyContent); + bodyContent = byteClamp.Content; + if (byteClamp.Truncated) + { + bodyContentTruncated = true; + if (byteClamp.ReturnedLineCount > 0) + { + bodyContentEndLine = Math.Min( + bodyExcerpt.EndLine, + bodyExcerpt.StartLine + byteClamp.ReturnedLineCount - 1); + var nextStartLine = byteClamp.LastReturnedLineComplete + ? bodyContentEndLine.Value + 1 + : bodyContentEndLine.Value; + bodyContentNextStartLine = Math.Clamp(nextStartLine, bodyExcerpt.StartLine, symbol.BodyEndLine.Value); + } + else + { + bodyContentEndLine = bodyExcerpt.StartLine; + bodyContentNextStartLine = bodyExcerpt.StartLine; + } + } + else if (cappedBodyEndLine < symbol.BodyEndLine.Value) bodyContentNextStartLine = cappedBodyEndLine + 1; } } @@ -855,21 +874,53 @@ public List GetDefinitions(string query, int limit = 20, strin return results; } - private static (string Content, bool Truncated) ClampDefinitionBodyBytes(string content) + private static (string Content, bool Truncated, int ReturnedLineCount, bool LastReturnedLineComplete) ClampDefinitionBodyBytes(string content) { var bytes = Encoding.UTF8.GetBytes(content); if (bytes.Length <= DefinitionBodyMaxBytes) - return (content, false); + return (content, false, CountReturnedBodyLines(content), true); var byteCount = DefinitionBodyMaxBytes; while (byteCount > 0 && IsUtf8ContinuationByte(bytes[byteCount])) byteCount--; - return (Encoding.UTF8.GetString(bytes, 0, byteCount), true); + var clamped = Encoding.UTF8.GetString(bytes, 0, byteCount); + return ( + clamped, + true, + CountReturnedBodyLines(clamped), + IsLastReturnedBodyLineComplete(content, clamped)); } private static bool IsUtf8ContinuationByte(byte value) => (value & 0xC0) == 0x80; + private static int CountReturnedBodyLines(string content) + { + if (content.Length == 0) + return 0; + + var lineBreaks = 0; + foreach (var ch in content) + { + if (ch == '\n') + lineBreaks++; + } + + return content[^1] == '\n' + ? lineBreaks + : lineBreaks + 1; + } + + private static bool IsLastReturnedBodyLineComplete(string originalContent, string clampedContent) + { + if (clampedContent.Length == 0) + return false; + if (clampedContent[^1] == '\n') + return true; + return clampedContent.Length >= originalContent.Length + || originalContent[clampedContent.Length] == '\n'; + } + private static string? BuildDefinitionDisambiguator(SymbolResult symbol) { if (!string.Equals(symbol.Lang, "csharp", StringComparison.OrdinalIgnoreCase)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 45680fb3ac..c0b361e1d2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -322,6 +322,54 @@ public int Compute() } } + [Fact] + public void RunInspect_BodyRangeJson_ReportsNextLineFromByteClampedContent_Issue3394() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_byte_clamp_range_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var longValue = new string('x', DbReader.DefinitionBodyMaxBytes + 1024); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + $$""" + public class Target + { + public string Compute() + { + var marker1 = "{{longValue}}"; + var marker2 = "after"; + return marker1 + marker2; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--body"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var definition = document.RootElement.GetProperty("definitions").EnumerateArray().Single(); + var bodyContent = definition.GetProperty("body_content").GetString(); + var bodyContentEndLine = definition.GetProperty("body_content_end_line").GetInt32(); + var bodyContentNextStartLine = definition.GetProperty("body_content_next_start_line").GetInt32(); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("var marker1", bodyContent, StringComparison.Ordinal); + Assert.DoesNotContain("var marker2", bodyContent, StringComparison.Ordinal); + Assert.True(definition.GetProperty("body_content_truncated").GetBoolean()); + Assert.Equal(bodyContentEndLine, bodyContentNextStartLine); + Assert.True(bodyContentNextStartLine < definition.GetProperty("body_end_line").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunInspect_JsonWithoutBody_IncludesBodyModeHint_Issue3441() { From bc815091fae29f6f425332444cb9728681169e9a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:27:02 +0900 Subject: [PATCH 5/5] Make inspect body byte continuation monotonic for #3394 --- USER_GUIDE.md | 8 ++++-- changelog.d/unreleased/3394.added.md | 4 +-- src/CodeIndex/Database/DbSymbolReader.cs | 28 ++++++------------- .../QueryCommandRunnerInspectTests.cs | 17 ++++++++++- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 74a4b9be8b..da6ab8e928 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -314,9 +314,11 @@ top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`; `--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 -`--body-start`; use `--body-lines` to choose the page size. `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. +`--body-start`; use `--body-lines` to choose the page size. If a single long +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. ```bash cdidx search authenticate --json # ndjson stream, one result per line diff --git a/changelog.d/unreleased/3394.added.md b/changelog.d/unreleased/3394.added.md index 038873bf7e..f4ed04d9e7 100644 --- a/changelog.d/unreleased/3394.added.md +++ b/changelog.d/unreleased/3394.added.md @@ -14,8 +14,8 @@ affected: ## English -- **Inspect can page long definition bodies (#3394)** — `inspect --body` now accepts `--body-start` and `--body-lines`, and JSON definition rows include the returned body slice range plus `body_content_next_start_line` for fetching the next page. +- **Inspect can page long definition bodies (#3394)** — `inspect --body` now accepts `--body-start` and `--body-lines`, and JSON definition rows include the returned body slice range plus `body_content_next_start_line` for fetching the next page; byte-capped single-line slices advance to the following source line so clients do not loop. ## 日本語 -- **長い定義 body を `inspect` で page 取得できるようになりました (#3394)** — `inspect --body` は `--body-start` と `--body-lines` を受け付け、JSON の definition 行には返却した body slice 範囲と次ページ用の `body_content_next_start_line` が含まれるようになりました。 +- **長い定義 body を `inspect` で page 取得できるようになりました (#3394)** — `inspect --body` は `--body-start` と `--body-lines` を受け付け、JSON の definition 行には返却した body slice 範囲と次ページ用の `body_content_next_start_line` が含まれるようになりました。byte cap に当たった単一行 slice でも次の source line へ進むため、client が同じ行で loop しません。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 3d6494d25d..f52b5ef915 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -825,15 +825,16 @@ public List GetDefinitions(string query, int limit = 20, strin bodyContentEndLine = Math.Min( bodyExcerpt.EndLine, bodyExcerpt.StartLine + byteClamp.ReturnedLineCount - 1); - var nextStartLine = byteClamp.LastReturnedLineComplete - ? bodyContentEndLine.Value + 1 - : bodyContentEndLine.Value; - bodyContentNextStartLine = Math.Clamp(nextStartLine, bodyExcerpt.StartLine, symbol.BodyEndLine.Value); + var nextStartLine = bodyContentEndLine.Value + 1; + if (nextStartLine <= symbol.BodyEndLine.Value) + bodyContentNextStartLine = nextStartLine; } else { bodyContentEndLine = bodyExcerpt.StartLine; - bodyContentNextStartLine = bodyExcerpt.StartLine; + var nextStartLine = bodyExcerpt.StartLine + 1; + if (nextStartLine <= symbol.BodyEndLine.Value) + bodyContentNextStartLine = nextStartLine; } } else if (cappedBodyEndLine < symbol.BodyEndLine.Value) @@ -874,11 +875,11 @@ public List GetDefinitions(string query, int limit = 20, strin return results; } - private static (string Content, bool Truncated, int ReturnedLineCount, bool LastReturnedLineComplete) ClampDefinitionBodyBytes(string content) + private static (string Content, bool Truncated, int ReturnedLineCount) ClampDefinitionBodyBytes(string content) { var bytes = Encoding.UTF8.GetBytes(content); if (bytes.Length <= DefinitionBodyMaxBytes) - return (content, false, CountReturnedBodyLines(content), true); + return (content, false, CountReturnedBodyLines(content)); var byteCount = DefinitionBodyMaxBytes; while (byteCount > 0 && IsUtf8ContinuationByte(bytes[byteCount])) @@ -888,8 +889,7 @@ private static (string Content, bool Truncated, int ReturnedLineCount, bool Last return ( clamped, true, - CountReturnedBodyLines(clamped), - IsLastReturnedBodyLineComplete(content, clamped)); + CountReturnedBodyLines(clamped)); } private static bool IsUtf8ContinuationByte(byte value) => (value & 0xC0) == 0x80; @@ -911,16 +911,6 @@ private static int CountReturnedBodyLines(string content) : lineBreaks + 1; } - private static bool IsLastReturnedBodyLineComplete(string originalContent, string clampedContent) - { - if (clampedContent.Length == 0) - return false; - if (clampedContent[^1] == '\n') - return true; - return clampedContent.Length >= originalContent.Length - || originalContent[clampedContent.Length] == '\n'; - } - private static string? BuildDefinitionDisambiguator(SymbolResult symbol) { if (!string.Equals(symbol.Lang, "csharp", StringComparison.OrdinalIgnoreCase)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index c0b361e1d2..ff3f90653a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; @@ -361,8 +362,22 @@ public string Compute() Assert.Contains("var marker1", bodyContent, StringComparison.Ordinal); Assert.DoesNotContain("var marker2", bodyContent, StringComparison.Ordinal); Assert.True(definition.GetProperty("body_content_truncated").GetBoolean()); - Assert.Equal(bodyContentEndLine, bodyContentNextStartLine); + Assert.Equal(bodyContentEndLine + 1, bodyContentNextStartLine); Assert.True(bodyContentNextStartLine < definition.GetProperty("body_end_line").GetInt32()); + + var (nextExitCode, nextStdout, nextStderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Compute", "--db", dbPath, "--json", "--body", "--body-start", bodyContentNextStartLine.ToString(CultureInfo.InvariantCulture)], + _jsonOptions)); + + using var nextDocument = ParseJsonOutput(nextStdout); + var nextDefinition = nextDocument.RootElement.GetProperty("definitions").EnumerateArray().Single(); + var nextBodyContent = nextDefinition.GetProperty("body_content").GetString(); + + Assert.Equal(CommandExitCodes.Success, nextExitCode); + Assert.Equal(string.Empty, nextStderr); + Assert.Equal(bodyContentNextStartLine, nextDefinition.GetProperty("body_content_start_line").GetInt32()); + Assert.Contains("var marker2", nextBodyContent, StringComparison.Ordinal); + Assert.DoesNotContain("var marker1", nextBodyContent, StringComparison.Ordinal); } finally {