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
13 changes: 13 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,13 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int

### Output format

Bounded projection fields are defined only in `ProjectionFieldRegistry`.
Runtime validation, `--fields list` discovery, compact defaults, alias
resolution, and command help all consume that registry. Field names are
case-sensitive; unknown values use the versioned `E010_USAGE_ERROR` command
error when JSON is requested, and discovery runs before query or database
access.

| Output mode | Contract |
|---|---|
| Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `outline`, `suggestions`) default to **human-readable output**. |
Expand Down Expand Up @@ -4683,6 +4690,12 @@ AI エージェント向け検索ルールのテンプレートについては

### 出力形式

bounded projection field は `ProjectionFieldRegistry` だけで定義します。
実行時検証、`--fields list` による発見、compact 既定値、alias 解決、command
help はすべてこのレジストリを参照します。field 名は大文字・小文字を区別し、未知の
値で JSON が要求されている場合は versioned `E010_USAGE_ERROR` command error を
返します。発見処理は query や database access より先に実行します。

| output mode | 契約 |
|---|---|
| human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`outline`、`suggestions`)は既定で**人間向け出力**です。 |
Expand Down
17 changes: 17 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,15 @@ command and filters. A cursor is bound to that selection and index generation,
so changed inputs or a refreshed index require restarting the pagination.
When a bounded `find --all` scan exits partially, its terminal record includes
`next_cursor`; replaying it resumes after the last scanned line.
The bounded-response commands `search`, `definition`, `find`, `status`,
`hotspots`, `references`, `callers`, `callees`, `symbols`, `files`,
`languages`, `impact`, and `map` validate `--fields` values case-sensitively
against one command-specific registry. Unknown names return a typed
`E010_USAGE_ERROR` instead of successful empty objects. Run
`cdidx <command> --fields list` before a query to obtain the machine-readable
catalog, including `all`, collection-qualified fields, aliases and their
targets, and explicit deprecation metadata. The catalog does not require a
query or index access.
For AI-oriented bounded payloads, `map`, `inspect`, and `outline` accept
`--compact`. It implies JSON output, caps list sections to 5 items by default
(or the explicit `--limit` / `--top` value), and adds `compact`,
Expand Down Expand Up @@ -3492,6 +3501,14 @@ cursor はその選択条件と index generation に束縛されるため、入
更新後は pagination を最初からやり直す必要があります。上限に達した
`find --all` scan が partial exit した場合、terminal record の `next_cursor` を
再利用すると最後に scan した line の次から継続します。
bounded-response command の `search`、`definition`、`find`、`status`、
`hotspots`、`references`、`callers`、`callees`、`symbols`、`files`、
`languages`、`impact`、`map` は、command ごとの単一レジストリに対して
`--fields` の値を大文字・小文字を区別して検証します。未知の名前では空 object の
まま成功せず、型付き `E010_USAGE_ERROR` を返します。query の実行前に
`cdidx <command> --fields list` を実行すると、`all`、collection 修飾 field、
alias とその参照先、明示的な deprecation metadata を含む機械可読 catalog を取得
できます。この catalog の取得には query も index access も不要です。
AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outline` は
`--compact` に対応しています。これは JSON 出力を暗黙に有効化し、list section を
既定 5 件(明示した `--limit` / `--top` があればその値)に cap し、
Expand Down
21 changes: 21 additions & 0 deletions changelog.d/unreleased/4836.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
category: fixed
issues:
- 4836
affected:
- src/CodeIndex/Cli/ProjectionFieldRegistry.cs
- src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs
- src/CodeIndex/Cli/ConsoleUi.Help.cs
- src/CodeIndex/Cli/CliFlagSchema.cs
- tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **Bounded response projections now use a discoverable command-specific field registry (#4836)** — `search`, `references`, `map`, and the other bounded-response commands validate case-sensitive `--fields` values before query execution, return typed usage errors with nearby candidates for unknown names, and expose valid fields, aliases, collections, and deprecation metadata through `--fields list`. Compact defaults, alias projection, and command help now consume the same registry.

## 日本語

- **bounded response の projection が発見可能な command 別 field registry を使用するようになりました (#4836)** — `search`、`references`、`map` などの bounded-response command は、query 実行前に大文字・小文字を区別して `--fields` の値を検証し、未知の名前には近い候補を含む型付き usage error を返します。`--fields list` で有効な field、alias、collection、deprecation metadata を取得でき、compact 既定値、alias projection、command help も同じ registry を参照します。
5 changes: 1 addition & 4 deletions src/CodeIndex/Cli/CliFlagSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ public static bool HasAuthoritativeHelpOptions(string command) =>
private static readonly string[] ByBucketCommands = ["unused"];
private static readonly string[] UnusedFilterCommands = ["unused"];
private static readonly string[] BoundedProjectionCommands =
[
"search", "definition", "find", "status", "hotspots", "references", "callers", "callees",
"symbols", "files", "languages", "impact", "map",
];
ProjectionFieldRegistry.SupportedCommands.ToArray();
private static readonly string[] CursorCommands = ["search", "outline", "unused", "deps", .. BoundedProjectionCommands];
private static readonly string[] AllResultCommands = ["goto", "find", "unused"];

Expand Down
12 changes: 10 additions & 2 deletions src/CodeIndex/Cli/ConsoleUi.Help.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,17 @@ public static bool PrintCommandUsage(string command)
foreach (var flag in helpFlags)
{
var names = flag.ShortName is null ? flag.Name : $"{flag.Name}, {flag.ShortName}";
var token = flag.ValuePlaceholder is null ? names : $"{names} {flag.ValuePlaceholder}";
var projectionFields = string.Equals(flag.Name, "--fields", StringComparison.Ordinal)
&& ProjectionFieldRegistry.SupportsCommand(schemaCommand);
var valuePlaceholder = projectionFields
? ProjectionFieldRegistry.GetHelpValuePlaceholder(schemaCommand)
: flag.ValuePlaceholder;
var description = projectionFields
? ProjectionFieldRegistry.GetHelpDescription(schemaCommand)
: flag.Description;
var token = valuePlaceholder is null ? names : $"{names} {valuePlaceholder}";
Console.WriteLine($" {token}");
Console.WriteLine($" {flag.Description}");
Console.WriteLine($" {description}");
}
}
var notes = GetCommandUsageNotes(command);
Expand Down
102 changes: 69 additions & 33 deletions src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ internal static partial class JsonEnvelopeWrapper
private const string ResponseCursorPrefix = "response:v2:";
private static readonly AsyncLocal<BoundedExecutionContext?> BoundedExecution = new();

private static readonly HashSet<string> BoundedResponseCommands = new(StringComparer.Ordinal)
{
"search", "definition", "find", "status", "hotspots", "references", "callers", "callees",
"symbols", "files", "languages", "impact", "map",
};
private static readonly HashSet<string> BoundedResponseCommands =
ProjectionFieldRegistry.SupportedCommands.ToHashSet(StringComparer.Ordinal);

private static readonly HashSet<string> AutoWrapByteBudgetCommands = new(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -57,23 +54,6 @@ internal static partial class JsonEnvelopeWrapper
"symbols", "files", "languages", "impact",
};

private static readonly Dictionary<string, string[]> CompactFieldsByCommand = new(StringComparer.Ordinal)
{
["search"] = ["file", "line"],
["definition"] = ["file", "line", "column"],
["find"] = ["file", "line", "column"],
["references"] = ["file", "line", "column"],
["callers"] = ["file", "line", "column"],
["callees"] = ["file", "line", "column"],
["hotspots"] = ["name", "kind", "path", "line", "reference_count", "reference_score", "ranking_score"],
["impact"] = ["path", "caller_name", "callee_name", "depth", "first_line", "reference_count", "result_kind"],
["symbols"] = ["path", "line", "kind", "name"],
["files"] = ["path", "lang", "lines"],
["languages"] = ["lang", "extensions", "symbol_extraction", "reference_extraction", "graph_queries"],
["status"] = ["api_version", "files", "chunks", "symbols", "references", "indexed_at", "git_head", "git_is_dirty", "head_freshness", "version", "graph_table_available", "hotspot_family_ready", "summary"],
["map"] = ["api_version", "file_count", "total_lines", "total_symbols", "total_references", "indexed_at", "git_head", "git_is_dirty", "head_freshness", "graph_table_available", "sections"],
};

internal static bool ShouldAutoWrapBoundedResponse(string command, string[] args)
{
if (!BoundedResponseCommands.Contains(command))
Expand Down Expand Up @@ -149,6 +129,29 @@ private static int RunBoundedResponse(
{
if (!TryParseBoundedResponseControls(command, args, out var controls, out var controlError))
return WriteBoundedResponseUsageError(controlError!, "Use the command help to pass positive --limit/--max-json-bytes values and a next_cursor returned by the same query.");
if (ProjectionFieldRegistry.IsDiscoveryRequest(controls.Fields))
{
var discoveryJson = ProjectionFieldRegistry.CreateDiscoveryDocument(command).ToJsonString(jsonOptions);
return WriteProjectionRegistryResponse(
discoveryJson,
CommandExitCodes.Success,
controls.MaxJsonBytes);
}
if (!ProjectionFieldRegistry.TryValidate(command, controls.Fields, out var fieldError))
{
var errorJson = JsonSerializer.Serialize(
new CommandErrorJsonResult(
"error",
fieldError!.Message,
fieldError.Hint,
CommandErrorCodes.UsageError,
Category: "usage"),
CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult);
return WriteProjectionRegistryResponse(
errorJson,
CommandExitCodes.UsageError,
controls.MaxJsonBytes);
}
if (HasArgument(args, "--count"))
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.");
if (command == "map" && ValidateMapProjectionControls(args, controls.Fields) is { } mapProjectionError)
Expand Down Expand Up @@ -248,7 +251,11 @@ private static int RunBoundedResponse(
var availableItems = extraction.Items;
var pageItems = availableItems
.Take(controls.PageLimit)
.Select(item => ProjectResponseItem(item, controls.EffectiveFields(command, extraction.PrimaryCollection)))
.Select(item => ProjectResponseItem(
item,
controls.EffectiveFields(command, extraction.PrimaryCollection),
command,
extraction.PrimaryCollection))
.ToList();

var count = executionContext?.ReportedTotalCount is { } reportedTotalCount
Expand Down Expand Up @@ -473,6 +480,22 @@ JsonObject BuildCandidate(int count)
private static bool JsonFitsResponseBudget(string json, int maxJsonBytes)
=> Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine) <= maxJsonBytes;

private static int WriteProjectionRegistryResponse(
string json,
int exitCode,
int? maxJsonBytes)
{
if (maxJsonBytes.HasValue && !JsonFitsResponseBudget(json, maxJsonBytes.Value))
{
return WriteBoundedResponseUsageError(
$"--max-json-bytes {maxJsonBytes.Value} is too small for the projection-field response.",
"Increase --max-json-bytes and rerun the same --fields request.");
}

Console.WriteLine(json);
return exitCode;
}

private static JsonObject? TakeCommandError(JsonArray rawResults, int exitCode)
{
if (exitCode == CommandExitCodes.Success
Expand Down Expand Up @@ -720,7 +743,11 @@ private static ResponseExtraction ExtractNestedCollection(JsonObject payload, st
return names.FirstOrDefault(name => payload[name] is JsonArray);
}

private static JsonNode? ProjectResponseItem(JsonNode? item, IReadOnlyList<string>? fields)
private static JsonNode? ProjectResponseItem(
JsonNode? item,
IReadOnlyList<string>? fields,
string command,
string? primaryCollection)
{
if (item is not JsonObject obj || fields is null || fields.Count == 0 || fields.Contains("all", StringComparer.Ordinal))
return item?.DeepClone();
Expand All @@ -729,15 +756,16 @@ private static ResponseExtraction ExtractNestedCollection(JsonObject payload, st
{
if (obj.TryGetPropertyValue(field, out var value))
projected[field] = value?.DeepClone();
else if (ProjectionFieldRegistry.TryResolveAlias(command, primaryCollection, field, out var sourceField)
&& obj.TryGetPropertyValue(sourceField, out var aliasValue))
projected[string.Equals(field, "body", StringComparison.Ordinal) ? sourceField : field] =
aliasValue?.DeepClone();
else if (string.Equals(field, "path", StringComparison.Ordinal)
&& obj.TryGetPropertyValue("file", out var file))
projected[field] = file?.DeepClone();
else if (string.Equals(field, "file", StringComparison.Ordinal)
&& obj.TryGetPropertyValue("path", out var path))
projected[field] = path?.DeepClone();
else if (string.Equals(field, "body", StringComparison.Ordinal)
&& obj.TryGetPropertyValue("body_content", out var bodyContent))
projected["body_content"] = bodyContent?.DeepClone();
}
return projected;
}
Expand Down Expand Up @@ -889,10 +917,14 @@ private static string[] PrepareBoundedInnerArgs(string command, string[] args, B

private static bool HasExplicitBodyProjection(IReadOnlyList<string>? fields)
=> fields?.Any(field =>
string.Equals(field, "all", StringComparison.Ordinal)
|| string.Equals(field, "body", StringComparison.Ordinal)
|| string.Equals(field, "body_content", StringComparison.Ordinal)
|| field.StartsWith("body_", StringComparison.Ordinal)) == true;
{
var separator = field.LastIndexOf('.');
var projectedField = separator >= 0 ? field[(separator + 1)..] : field;
return string.Equals(field, "all", StringComparison.Ordinal)
|| string.Equals(projectedField, "body", StringComparison.Ordinal)
|| string.Equals(projectedField, "body_content", StringComparison.Ordinal)
|| projectedField.StartsWith("body_", StringComparison.Ordinal);
}) == true;

private static string? ValidateMapProjectionControls(string[] args, IReadOnlyList<string>? fields)
{
Expand Down Expand Up @@ -1349,7 +1381,7 @@ private sealed record BoundedResponseControls(
var preserveFullDiscoveryRows = command is "search" or "languages";
var selected = Fields
?? ((!preserveFullDiscoveryRows || Compact)
&& CompactFieldsByCommand.TryGetValue(command, out var defaults)
&& ProjectionFieldRegistry.GetCompactFields(command) is { } defaults
? defaults
: null);
if (selected is null || primaryCollection is null)
Expand Down Expand Up @@ -1504,7 +1536,11 @@ internal static bool IsBoundedMapScalarProjection()
return execution is not null
&& string.Equals(execution.Command, "map", StringComparison.Ordinal)
&& GetBoundedMapCollection() is null
&& execution.Fields is { Count: > 0 };
&& execution.Fields is { Count: > 0 } fields
&& !fields.Any(field =>
string.Equals(field, "language_count", StringComparison.Ordinal)
|| string.Equals(field, "module_count", StringComparison.Ordinal)
|| string.Equals(field, "entrypoint_count", StringComparison.Ordinal));
}

internal static string? GetBoundedImpactCollection()
Expand Down
Loading
Loading