Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int
| Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `outline`, `suggestions`) default to **human-readable output**. |
| `--json` | Emits JSON lines output, one JSON object per line, designed for easy parsing by AI agents. |
| `definition --json` miss | A default-format definition lookup that finds no matching symbol emits the shared versioned `E018_QUERY_NOT_FOUND` command-error object and exits `2`, with or without `--body`; it never succeeds with empty stdout. Bounded-envelope controls move the object to `metadata.error` and keep `results` empty instead of projecting it as a location row. The object is preflighted against `--max-json-bytes`; an impossible cap returns a usage error without oversized stdout. `--count` still returns its structured zero-count object, and explicit location formats retain their existing format-specific empty-result output. |
| Raw discovery JSON shape | `symbols` and `files` preserve the selected flat shape for every result cardinality and under `--max-json-bytes`: zero-result NDJSON is an empty stream, `--json=array` is always an array, and byte-capped output omits whole trailing rows without changing the top-level type. Use `--format compact` or `--json-envelope` when truncation and freshness metadata must accompany the results. |
| Raw discovery JSON shape | `symbols` and `files` build each result row through the same DTO path for array, NDJSON, and envelope output. `symbols --json=array` therefore preserves `exact_index_available` just like NDJSON. Every cardinality and `--max-json-bytes` path keeps the selected flat shape: zero-result NDJSON is an empty stream, `--json=array` is always an array, and byte-capped output omits whole trailing rows without changing the top-level type. Bounded projections keep rows in `results`, pagination facts in `metadata`, and exact-query readiness in `metadata.response_context`; they never reuse a result row as response context. Use `--format compact` or `--json-envelope` when truncation and freshness metadata must accompany the results. |
| Generated-code filtering metadata | DB-backed discovery `query_context` always reports `include_generated`, `generated_code_policy`, and `generated_file_filter_available`. The `files --count --json` and every JSON `map` summary (including `issue-drafts`) also report `generated_file_count_excluded` and `generated_file_count_excluded_authoritative`. The excluded count is `0` when generated files are included. For a legacy DB without `files.generated` when filtering is requested, the policy is `unavailable`, the count is `null`, and the authoritative/available flags are `false` rather than claiming that an unavailable filter ran; explicit `--include-generated` remains `include` with an authoritative excluded count of `0`. Byte-capped and uncapped raw discovery arrays retain SQLite trust diagnostics even when the query returns no result rows. |
| Map scope, depth, and freshness | `map --depth <n>` applies path, language, test, generated-code, and exclusion filters before aggregating modules by the requested prefix depth. Scoped map output excludes the workspace-global decomposition plan. Workspace HEAD metadata is read in one query from the same SQLite snapshot as the map and remains explicit under `head_freshness`: `scope=workspace`, `indexed_head_source=latest_index` for the current successful index stamp (or `legacy_full_scan` only when it is the fallback), and `legacy_full_scan_head` for the separately labeled compatibility stamp. `issue-drafts` evaluates every scoped file for its thresholds, so `candidate_source=evaluated_scoped_candidates`, candidate counts, group totals, omitted counts, and `truncation.issue_draft_candidates` are candidate-based even though candidate details remain bounded; `truncation.largest_files` is a labeled compatibility alias only. |
| `test-extractor` JSON | Machine-readable `test-extractor` success uses a versioned `{"api_version":"1","symbols":[...]}` envelope; the nested symbol objects retain their established property names. `--json` failures use the shared versioned command-error contract. |
Expand Down Expand Up @@ -4684,7 +4684,7 @@ AI エージェント向け検索ルールのテンプレートについては
| human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`outline`、`suggestions`)は既定で**人間向け出力**です。 |
| `--json` | JSON lines output(1 行 1 JSON object)に切り替えます。AI agent が容易に parse できるよう設計されています。 |
| `definition --json` の未検出 | 既定 format の definition lookup で一致する symbol がない場合、`--body` の有無にかかわらず、共通の versioned `E018_QUERY_NOT_FOUND` command-error object を出力して終了コード `2` を返します。空の stdout のまま成功することはありません。bounded-envelope control の使用時は object を location row として projection せず `metadata.error` に移し、`results` は空のままにします。この object は `--max-json-bytes` に対して事前検査され、収まらない上限では oversized stdout を出さず usage error を返します。`--count` は引き続き構造化された 0 件 object を返し、明示的な location format も既存の format 固有の empty-result output を維持します。 |
| raw discovery JSON shape | `symbols` と `files` は、結果件数や `--max-json-bytes` の有無にかかわらず、選択した flat shape を維持します。0 件の NDJSON は空 stream、`--json=array` は常に array となり、byte cap 到達時は top-level type を変えずに末尾の完全な row を省略します。truncation / freshness metadata も結果と一緒に必要な場合は `--format compact` または `--json-envelope` を使用します。 |
| raw discovery JSON shape | `symbols` と `files` は、array、NDJSON、envelope の各出力で同じ DTO 経路から result row を構築します。そのため `symbols --json=array` も NDJSON と同様に `exact_index_available` を保持します。結果件数や `--max-json-bytes` の有無にかかわらず選択した flat shape を維持し、0 件の NDJSON は空 stream、`--json=array` は常に array となり、byte cap 到達時は top-level type を変えずに末尾の完全な row を省略します。bounded projection は row を `results`、pagination fact を `metadata`、exact-query readiness を `metadata.response_context` に保持し、result row を response context として再利用しません。truncation / freshness metadata も結果と一緒に必要な場合は `--format compact` または `--json-envelope` を使用します。 |
| generated-code filtering metadata | DB-backed discovery の `query_context` は常に `include_generated`、`generated_code_policy`、`generated_file_filter_available` を返します。`files --count --json` と `issue-drafts` を含むすべての JSON `map` summary は、`generated_file_count_excluded` と `generated_file_count_excluded_authoritative` も返します。generated file を含める場合、除外数は `0` です。`files.generated` が無い legacy DB で filter が要求された場合、未実行の filter を実行済みと誤認させないよう、policy は `unavailable`、count は `null`、authoritative / available flag は `false` になります。明示的な `--include-generated` は `include` のままで、authoritative な除外数 `0` を返します。byte cap の有無にかかわらず、raw discovery array は query result row が 0 件でも SQLite trust diagnostics を維持します。 |
| map の scope、depth、freshness | `map --depth <n>` は path、language、test、generated-code、除外条件を適用してから、指定した prefix depth で module を集計します。scope を絞った map output からは workspace 全体向けの decomposition plan を除外します。workspace HEAD metadata は map と同じ SQLite snapshot から 1 query で読み、`head_freshness` に `scope=workspace`、現在の成功 index stamp なら `indexed_head_source=latest_index`(fallback の場合だけ `legacy_full_scan`)、互換用 stamp は別名の `legacy_full_scan_head` として明示します。`issue-drafts` は scope 内の全 file を閾値評価するため、`candidate_source=evaluated_scoped_candidates`、candidate 件数、group 合計、省略数、`truncation.issue_draft_candidates` は candidate 基準になります。`truncation.largest_files` は明示的な互換 alias としてのみ残します。 |
| `test-extractor` JSON | 機械可読な `test-extractor` success は versioned `{"api_version":"1","symbols":[...]}` envelope を使い、内側の symbol object は既存の property 名を維持します。`--json` failure は共通の versioned command-error 契約を使います。 |
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/4835.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 4835
affected:
- src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs
- src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs
- tests/CodeIndex.Tests/QueryCommandRunnerIssue4835Tests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Projected `symbols` and `files` rows now stay consistent across JSON modes (#4835)** — array, NDJSON, and envelope output now reuse the same discovery-row serialization, preserve exact-query readiness, and keep projected rows in `results` with truthful count, truncation, and cursor metadata instead of relocating the first row to `metadata.response_context`.

## 日本語

- **投影した `symbols` / `files` の row が JSON mode 間で一貫するようになりました (#4835)** — array、NDJSON、envelope 出力が同じ discovery-row serialization を再利用し、exact-query readiness を維持するようになりました。また、先頭 row を `metadata.response_context` に移動せず `results` に保持し、件数・truncation・cursor metadata を正しく返します。
70 changes: 66 additions & 4 deletions src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ private static int RunBoundedResponse(
reportedTotalCount,
executionContext.ReportedTotalCountAuthoritative)
: ResolveTotalCount(command, args, runInner, extraction, availableItems.Count, controls.Offset, streamTerminal);
if (count.Context is not null)
extraction = extraction with { Context = MergeResponseContexts(extraction.Context, count.Context) };
var totalCount = Math.Max(count.TotalCount, controls.Offset + pageItems.Count);
var totalAuthoritative = count.Authoritative;
var completedSnapshot = SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion);
Expand Down Expand Up @@ -586,9 +588,13 @@ private static ResponseExtraction ExtractResponseItems(string command, JsonArray
}
if (command == "hotspots" && rawResults.FirstOrDefault() is JsonObject hotspotsPayload)
return ExtractNestedCollection(hotspotsPayload, "hotspots");
if (command == "symbols" && rawResults.FirstOrDefault() is JsonObject symbolsPayload)
if (command == "symbols"
&& rawResults.FirstOrDefault() is JsonObject symbolsPayload
&& (symbolsPayload["symbols"] is JsonArray || ReadOptionalBool(symbolsPayload, "summary_only")))
return ExtractNestedCollection(symbolsPayload, "symbols");
if (command == "files" && rawResults.FirstOrDefault() is JsonObject filesPayload)
if (command == "files"
&& rawResults.FirstOrDefault() is JsonObject filesPayload
&& (filesPayload["files"] is JsonArray || ReadOptionalBool(filesPayload, "summary_only")))
return ExtractNestedCollection(filesPayload, "files");
if (command == "languages" && rawResults.FirstOrDefault() is JsonObject languagesPayload)
return ExtractNestedCollection(languagesPayload, "languages");
Expand Down Expand Up @@ -622,6 +628,8 @@ private static ResponseExtraction ExtractResponseItems(string command, JsonArray
mapPayload);
}
}
if (command is "symbols" or "files")
return ExtractDiscoveryRows(command, rawResults);
if (rawResults.Count == 1 && rawResults[0] is JsonArray arrayPayload)
{
return new ResponseExtraction(
Expand All @@ -641,6 +649,40 @@ private static ResponseExtraction ExtractResponseItems(string command, JsonArray
return new ResponseExtraction(rows, null, null, null);
}

private static ResponseExtraction ExtractDiscoveryRows(string command, JsonArray rawResults)
{
var rows = new JsonArray();
JsonObject? context = null;
foreach (var result in rawResults)
{
if (result is JsonObject obj && IsJsonStreamTerminal(obj))
continue;
rows.Add(result?.DeepClone());
if (command != "symbols" || result is not JsonObject row)
continue;

if (row.TryGetPropertyValue("exact_index_available", out var exactIndexAvailable))
{
context ??= new JsonObject();
context["exact_index_available"] = exactIndexAvailable?.DeepClone();
}
if (row.TryGetPropertyValue("degraded_reason", out var degradedReason))
{
context ??= new JsonObject();
context["degraded_reason"] = degradedReason?.DeepClone();
}
}
return new ResponseExtraction(rows, command, context, null);
}

private static JsonObject MergeResponseContexts(JsonObject? existing, JsonObject additional)
{
var merged = existing is null ? new JsonObject() : (JsonObject)existing.DeepClone();
foreach (var property in additional)
merged[property.Key] = property.Value?.DeepClone();
return merged;
}

private static ResponseExtraction ExtractNestedCollection(JsonObject payload, string collectionName)
{
var items = payload[collectionName] as JsonArray ?? [];
Expand Down Expand Up @@ -777,7 +819,7 @@ private static ResponseCount ResolveTotalCount(
&& !ReadOptionalBool(countPayload, "degraded")
&& ReadOptionalBool(countPayload, "graph_table_available", defaultValue: true)
&& ReadOptionalBool(countPayload, "hotspot_family_ready", defaultValue: true);
return new ResponseCount(total, authoritative);
return new ResponseCount(total, authoritative, ExtractCountResponseContext(command, countPayload));
}
catch
{
Expand All @@ -802,6 +844,23 @@ private static bool TryReadBool(JsonObject obj, string propertyName, out bool va
private static bool ReadOptionalBool(JsonObject obj, string propertyName, bool defaultValue = false)
=> TryReadBool(obj, propertyName, out var value) ? value : defaultValue;

private static JsonObject? ExtractCountResponseContext(string command, JsonObject countPayload)
{
if (command != "symbols"
|| !countPayload.TryGetPropertyValue("exact_index_available", out var exactIndexAvailable))
{
return null;
}

var context = new JsonObject
{
["exact_index_available"] = exactIndexAvailable?.DeepClone(),
};
if (countPayload.TryGetPropertyValue("degraded_reason", out var degradedReason))
context["degraded_reason"] = degradedReason?.DeepClone();
return context;
}

private static string? ReadString(JsonObject? obj, string propertyName)
=> obj?[propertyName] is JsonValue value && value.TryGetValue<string>(out var text)
? text
Expand Down Expand Up @@ -1313,7 +1372,10 @@ private sealed record ResponseExtraction(
JsonObject? Context,
JsonObject? SourcePayload);

private readonly record struct ResponseCount(int TotalCount, bool Authoritative);
private readonly record struct ResponseCount(
int TotalCount,
bool Authoritative,
JsonObject? Context = null);

private readonly record struct ResponseSnapshot(
string GenerationFingerprint,
Expand Down
Loading
Loading