diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 4607a3f63..2983801a5 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -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**. | @@ -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`)は既定で**人間向け出力**です。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6e21889c7..caebefd90 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -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 --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`, @@ -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 --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 し、 diff --git a/changelog.d/unreleased/4836.fixed.md b/changelog.d/unreleased/4836.fixed.md new file mode 100644 index 000000000..ed9626311 --- /dev/null +++ b/changelog.d/unreleased/4836.fixed.md @@ -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 を参照します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 2be1d2d2f..b3f82c243 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -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"]; diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 506ee277a..33dd9e1ab 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -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); diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index eed13127b..4dc055135 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -23,11 +23,8 @@ internal static partial class JsonEnvelopeWrapper private const string ResponseCursorPrefix = "response:v2:"; private static readonly AsyncLocal BoundedExecution = new(); - private static readonly HashSet BoundedResponseCommands = new(StringComparer.Ordinal) - { - "search", "definition", "find", "status", "hotspots", "references", "callers", "callees", - "symbols", "files", "languages", "impact", "map", - }; + private static readonly HashSet BoundedResponseCommands = + ProjectionFieldRegistry.SupportedCommands.ToHashSet(StringComparer.Ordinal); private static readonly HashSet AutoWrapByteBudgetCommands = new(StringComparer.Ordinal) { @@ -57,23 +54,6 @@ internal static partial class JsonEnvelopeWrapper "symbols", "files", "languages", "impact", }; - private static readonly Dictionary 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)) @@ -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) @@ -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 @@ -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 @@ -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? fields) + private static JsonNode? ProjectResponseItem( + JsonNode? item, + IReadOnlyList? 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(); @@ -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; } @@ -889,10 +917,14 @@ private static string[] PrepareBoundedInnerArgs(string command, string[] args, B private static bool HasExplicitBodyProjection(IReadOnlyList? 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? fields) { @@ -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) @@ -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() diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs new file mode 100644 index 000000000..3f8efa7b0 --- /dev/null +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -0,0 +1,445 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; + +namespace CodeIndex.Cli; + +/// +/// Canonical, command-specific registry for bounded-response projection fields. +/// Runtime validation, machine discovery, compact defaults, and command help all +/// consume this registry so their contracts cannot drift independently. Issue #4836. +/// bounded-response の投影フィールドをコマンド別に管理する正規レジストリ。 +/// 実行時検証・機械向け発見・compact 既定値・コマンドヘルプはすべてこの +/// レジストリを参照し、契約の独立したずれを防ぐ。Issue #4836。 +/// +internal static class ProjectionFieldRegistry +{ + private const string DiscoveryValue = "list"; + + private static readonly IReadOnlyDictionary Schemas = + new Dictionary(StringComparer.Ordinal) + { + ["search"] = CreateSearchSchema(), + ["definition"] = CreateDefinitionSchema(), + ["find"] = CreateFindSchema(), + ["status"] = CreateStatusSchema(), + ["hotspots"] = CreateHotspotsSchema(), + ["references"] = CreateReferencesSchema(), + ["callers"] = CreateCallGraphSchema("callers"), + ["callees"] = CreateCallGraphSchema("callees"), + ["symbols"] = CreateSymbolsSchema(), + ["files"] = CreateFilesSchema(), + ["languages"] = CreateLanguagesSchema(), + ["impact"] = CreateImpactSchema(), + ["map"] = CreateMapSchema(), + }; + + internal static IReadOnlyList SupportedCommands { get; } = + Schemas.Keys.OrderBy(command => command, StringComparer.Ordinal).ToArray(); + + internal static bool SupportsCommand(string command) => Schemas.ContainsKey(command); + + internal static bool IsDiscoveryRequest(IReadOnlyList? fields) + => fields is { Count: 1 } + && string.Equals(fields[0], DiscoveryValue, StringComparison.Ordinal); + + internal static IReadOnlyList? GetCompactFields(string command) + => Schemas.TryGetValue(command, out var schema) ? schema.CompactFields : null; + + internal static bool TryResolveAlias( + string command, + string? collection, + string requestedField, + out string sourceField) + { + sourceField = string.Empty; + if (!Schemas.TryGetValue(command, out var schema)) + return false; + var registryName = collection is null ? requestedField : $"{collection}.{requestedField}"; + var definition = schema.Fields.FirstOrDefault(field => + string.Equals(field.Name, registryName, StringComparison.Ordinal)); + if (definition?.AliasFor is null) + return false; + sourceField = collection is not null + && definition.AliasFor.StartsWith(collection + ".", StringComparison.Ordinal) + ? definition.AliasFor[(collection.Length + 1)..] + : definition.AliasFor; + return true; + } + + internal static string GetHelpValuePlaceholder(string command) + => SupportsCommand(command) ? "" : ""; + + internal static string GetHelpDescription(string command) + => $"Project validated {command} response fields (case-sensitive); use --fields list for the machine-readable catalog."; + + internal static bool TryValidate( + string command, + IReadOnlyList? requestedFields, + out ProjectionFieldValidationError? error) + { + error = null; + if (requestedFields is null || !Schemas.TryGetValue(command, out var schema)) + return true; + + if (requestedFields.Contains(DiscoveryValue, StringComparer.Ordinal)) + { + error = new ProjectionFieldValidationError( + $"The --fields discovery value '{DiscoveryValue}' must be used by itself for command '{command}'.", + $"Run `cdidx {command} --fields {DiscoveryValue}` without other field names."); + return false; + } + + foreach (var requestedField in requestedFields) + { + if (schema.ValidFieldNames.Contains(requestedField)) + continue; + + var nearby = ConsoleUi.FindClosestMatches( + requestedField, + schema.ValidFieldNames.Where(field => !string.Equals(field, "all", StringComparison.Ordinal))); + var candidateHint = nearby.Count > 0 + ? $" Nearby valid fields: {string.Join(", ", nearby)}." + : $" Valid fields include: {string.Join(", ", schema.ValidFieldNames.Take(8))}."; + error = new ProjectionFieldValidationError( + $"Unknown --fields value '{requestedField}' for command '{command}'.", + $"{candidateHint.TrimStart()} Run `cdidx {command} --fields {DiscoveryValue}` for the complete catalog."); + return false; + } + + return true; + } + + internal static JsonObject CreateDiscoveryDocument(string command) + { + var schema = Schemas[command]; + var fields = new JsonArray(); + foreach (var definition in schema.Fields) + { + var item = new JsonObject + { + ["name"] = definition.Name, + ["kind"] = definition.Kind, + ["deprecated"] = definition.Deprecated, + }; + if (definition.AliasFor is not null) + item["alias_for"] = definition.AliasFor; + if (definition.Collection is not null) + item["collection"] = definition.Collection; + fields.Add(item); + } + + return new JsonObject + { + ["api_version"] = "1", + ["command"] = command, + ["case_sensitive"] = true, + ["discovery_value"] = DiscoveryValue, + ["valid_fields"] = new JsonArray( + schema.ValidFieldNames.Select(field => (JsonNode?)field).ToArray()), + ["fields"] = fields, + }; + } + + private static ProjectionCommandFieldSchema CreateSearchSchema() + => Create( + "search", + ["file", "line"], + builder => builder + .Fields(GetJsonFieldNames()) + .Fields( + "api_version", "query", "path", "lang", "visibility", "chunk_start_line", + "chunk_end_line", "snippet_start_line", "snippet_end_line", "snippet", + "match_lines", "highlights", "match_origins", "match_facets", "result_kinds", + "test_file", "test_symbol", "test_fixture", "context_before", "context_after", + "truncated_line_count", "dropped_match_line_count", "snippet_lines", "max_line_width", + "exact", "raw_fts", "literal_highlights_available", "focus_mode", "focus_line", + "focus_column", "focus_reason", "next_match", "truncation_context", "score", + "enclosing_symbol_name", "enclosing_symbol_kind", "enclosing_symbol_start_line", + "enclosing_symbol_end_line", "enclosing_container_name") + .Alias("file", "path") + .Alias("line", "snippet_start_line")); + + private static ProjectionCommandFieldSchema CreateDefinitionSchema() + => Create( + "definition", + ["file", "line", "column"], + builder => builder + .Fields( + "disambiguator", "api_version", "path", "symbol_id", "lang", "kind", "sub_kind", + "name", "line", "start_line", "start_column", "end_line", "body_start_line", + "body_end_line", "signature", "container_kind", "container_name", "visibility", + "return_type", "definition_sites", "exact_index_available", "degraded_reason", + "content_omitted", "content_omitted_reason", "body_content", "body_content_start_line", + "body_content_end_line", "body_requested_start_line", "body_requested_end_line", + "body_effective_start_line", "body_effective_end_line", "body_content_truncated", + "body_content_truncation_reasons", "body_content_recovery", + "body_content_next_start_line", "complexity") + .Alias("file", "path") + .Alias("column", "start_column") + .Alias("body", "body_content")); + + private static ProjectionCommandFieldSchema CreateFindSchema() + => Create( + "find", + ["file", "line", "column"], + builder => builder + .Fields(GetJsonFieldNames()) + .Fields( + "api_version", "path", "lang", "line", "column", "length", "original_line_length", + "start_line", "end_line", "snippet", "snippet_truncated", + "snippet_truncation_context") + .Alias("file", "path")); + + private static ProjectionCommandFieldSchema CreateStatusSchema() + => Create( + "status", + [ + "api_version", "files", "chunks", "symbols", "references", "indexed_at", "git_head", + "git_is_dirty", "head_freshness", "version", "graph_table_available", + "hotspot_family_ready", "summary", + ], + builder => builder + .Fields(GetJsonFieldNames()) + .Fields( + "effective_config", "log_path", "field", "label", "ready", "degraded", + "remediation", "known_fields")); + + private static ProjectionCommandFieldSchema CreateHotspotsSchema() + => Create( + "hotspots", + ["name", "kind", "path", "line", "reference_count", "reference_score", "ranking_score"], + builder => builder + .Fields(GetJsonFieldNames()) + .Fields(GetJsonFieldNames()) + .Fields( + "name", "kind", "path", "line", "reference_count", "reference_score", + "ranking_score", "generic_name_penalty", "structural_rank_penalty", + "symbol_count", "lang", "visibility", "container") + .Alias("file", "path")); + + private static ProjectionCommandFieldSchema CreateReferencesSchema() + => Create( + "references", + ["file", "line", "column"], + builder => builder + .Fields(GetJsonFieldNames()) + .Fields( + "api_version", "path", "lang", "symbol_name", "target_symbol_id", + "target_symbol_key", "reference_kind", "line", "column", "context", + "context_truncated", "container_kind", "container_name", "is_self_reference", + "is_mutual_recursion", "resolution_state", "resolution_candidate_count") + .Alias("file", "path")); + + private static ProjectionCommandFieldSchema CreateCallGraphSchema(string command) + { + var isCallerCommand = string.Equals(command, "callers", StringComparison.Ordinal); + var resultFields = isCallerCommand + ? GetJsonFieldNames() + : GetJsonFieldNames(); + var compactFields = isCallerCommand + ? new[] { "file", "line", "column" } + : ["file", "line"]; + return Create( + command, + compactFields, + builder => + { + builder + .Fields(resultFields) + .Fields( + "reference_extraction_limits", "reference_graph_complete", + "reference_extraction_cap_hits") + .Alias("file", "path") + .Alias("line", "first_line"); + if (isCallerCommand) + builder.Alias("column", "first_column"); + }); + } + + private static ProjectionCommandFieldSchema CreateSymbolsSchema() + => Create( + "symbols", + ["path", "line", "kind", "name"], + builder => builder + .Fields(GetJsonFieldNames()) + .Alias("file", "path")); + + private static ProjectionCommandFieldSchema CreateFilesSchema() + => Create( + "files", + ["path", "lang", "lines"], + builder => builder + .Fields( + "api_version", "path", "lang", "size", "lines", "symbol_count", + "reference_count", "checksum", "modified", "indexed_at", "generated") + .Alias("file", "path")); + + private static ProjectionCommandFieldSchema CreateLanguagesSchema() + => Create( + "languages", + ["lang", "extensions", "symbol_extraction", "reference_extraction", "graph_queries"], + builder => builder.Fields(GetJsonFieldNames())); + + private static ProjectionCommandFieldSchema CreateImpactSchema() + { + var callerFields = GetJsonFieldNames().ToArray(); + var fileImpactFields = new[] + { + "result_kind", "path", "lang", "depth", "reference_count", "reference_kind", + "reference_kinds", "reference_kind_counts", + }; + var definitionFields = new[] + { + "api_version", "path", "symbol_id", "lang", "kind", "sub_kind", "name", "line", + "start_line", "start_column", "end_line", "body_start_line", "body_end_line", + "signature", "container_kind", "container_name", "visibility", "return_type", + "definition_sites", + }; + return Create( + "impact", + ["path", "caller_name", "callee_name", "depth", "first_line", "reference_count", "result_kind"], + builder => builder + .Fields(callerFields.Concat(fileImpactFields).Concat(definitionFields).Distinct(StringComparer.Ordinal)) + .Alias("file", "path") + .Collection("callers", callerFields, pathAlias: true) + .Collection("file_impacts", fileImpactFields, pathAlias: true) + .Collection("definitions", definitionFields, pathAlias: true)); + } + + private static ProjectionCommandFieldSchema CreateMapSchema() + { + var fileFields = new[] { "path", "lang", "lines", "size", "symbol_count", "reference_count" }; + return Create( + "map", + [ + "api_version", "file_count", "total_lines", "total_symbols", "total_references", + "indexed_at", "git_head", "git_is_dirty", "head_freshness", "graph_table_available", + "languages", "modules", "entrypoints", + ], + builder => builder + .Fields( + "api_version", "file_count", "total_lines", "total_symbols", "total_references", + "indexed_at", "latest_modified", "workspace_indexed_at", "workspace_latest_modified", + "project_root", "git_head", "git_is_dirty", "indexed_head_commit", "indexed_head_sha", + "indexed_head_branch", "indexed_head_timestamp", "commits_ahead_of_indexed_head", + "worktree_head_changed", "head_freshness", "language_count", "module_count", + "entrypoint_count", "graph_table_available", "generated_code_policy", + "generated_file_count_excluded", "generated_file_count_excluded_authoritative", + "generated_file_filter_available", "decomposition_plan", "summary_only", "sections", + "section_properties", "depth", "output_byte_limit", "compact", "compact_limit", + "next_commands", "truncation") + .Collection("languages", ["lang", "files", "lines", "symbols", "references"]) + .Collection("modules", ["module", "files", "lines", "symbols", "references"]) + .Collection( + "top_files", + ["path", "lang", "lines", "size", "symbol_count", "reference_count", "score"], + pathAlias: true) + .Collection("largest_files", fileFields, pathAlias: true) + .Collection("symbol_rich_files", fileFields, pathAlias: true) + .Collection("reference_rich_files", fileFields, pathAlias: true) + .Collection( + "entrypoints", + ["path", "lang", "kind", "name", "line", "score", "match_type", "confidence", "hint_rank"], + pathAlias: true)); + } + + private static IEnumerable GetJsonFieldNames() + => typeof(T) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .OrderBy(property => property.MetadataToken) + .Where(property => + property.GetCustomAttribute()?.Condition + is not JsonIgnoreCondition.Always) + .Select(property => + property.GetCustomAttribute()?.Name + ?? JsonNamingPolicy.SnakeCaseLower.ConvertName(property.Name)); + + private static ProjectionCommandFieldSchema Create( + string command, + IReadOnlyList compactFields, + Action configure) + { + var builder = new ProjectionFieldSchemaBuilder(); + configure(builder); + return builder.Build(command, compactFields); + } + + private sealed class ProjectionFieldSchemaBuilder + { + private readonly List _fields = + [ + new("all", "selector", AliasFor: null, Deprecated: false, Collection: null), + ]; + + private readonly HashSet _names = new(StringComparer.Ordinal) { "all" }; + + internal ProjectionFieldSchemaBuilder Fields(params string[] names) + => Fields((IEnumerable)names); + + internal ProjectionFieldSchemaBuilder Fields(IEnumerable names) + { + foreach (var name in names) + Add(new ProjectionFieldDefinition(name, "field", null, false, null)); + return this; + } + + internal ProjectionFieldSchemaBuilder Alias(string name, string target) + { + Add(new ProjectionFieldDefinition(name, "alias", target, false, null)); + return this; + } + + internal ProjectionFieldSchemaBuilder Collection( + string name, + IEnumerable fields, + bool pathAlias = false) + { + Add(new ProjectionFieldDefinition(name, "collection", null, false, name)); + foreach (var field in fields) + Add(new ProjectionFieldDefinition($"{name}.{field}", "field", null, false, name)); + if (pathAlias) + Add(new ProjectionFieldDefinition($"{name}.file", "alias", $"{name}.path", false, name)); + return this; + } + + internal ProjectionCommandFieldSchema Build(string command, IReadOnlyList compactFields) + { + var missingCompactField = compactFields.FirstOrDefault(field => !_names.Contains(field)); + if (missingCompactField is not null) + { + throw new InvalidOperationException( + $"Compact projection field '{missingCompactField}' is not registered for command '{command}'."); + } + return new ProjectionCommandFieldSchema( + command, + _fields.ToArray(), + _fields.Select(field => field.Name).ToArray(), + compactFields.ToArray()); + } + + private void Add(ProjectionFieldDefinition definition) + { + if (!_names.Add(definition.Name)) + return; + _fields.Add(definition); + } + } +} + +internal sealed record ProjectionFieldValidationError(string Message, string Hint); + +internal sealed record ProjectionFieldDefinition( + string Name, + string Kind, + string? AliasFor, + bool Deprecated, + string? Collection); + +internal sealed record ProjectionCommandFieldSchema( + string Command, + IReadOnlyList Fields, + IReadOnlyList ValidFieldNames, + IReadOnlyList CompactFields); diff --git a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs new file mode 100644 index 000000000..e1f3aa2d5 --- /dev/null +++ b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs @@ -0,0 +1,321 @@ +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class ProjectionFieldRegistryIssue4836Tests +{ + private readonly JsonSerializerOptions _jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + + [Theory] + [InlineData("search", "path")] + [InlineData("references", "resolution_state")] + [InlineData("map", "languages.lang")] + public void FieldsList_DiscoversCommandSpecificSchemaWithoutRunningQuery_Issue4836( + string command, + string expectedField) + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run([command, "--fields", "list"], _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal("1", root.GetProperty("api_version").GetString()); + Assert.Equal(command, root.GetProperty("command").GetString()); + Assert.True(root.GetProperty("case_sensitive").GetBoolean()); + Assert.Equal("list", root.GetProperty("discovery_value").GetString()); + Assert.Contains( + root.GetProperty("valid_fields").EnumerateArray(), + field => field.GetString() == expectedField); + Assert.All( + root.GetProperty("fields").EnumerateArray(), + field => Assert.False(field.GetProperty("deprecated").GetBoolean())); + } + + [Theory] + [InlineData("search")] + [InlineData("references")] + [InlineData("map")] + public void UnknownFields_ReturnTypedJsonUsageErrorBeforeQueryExecution_Issue4836(string command) + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run([command, "--fields", "bogus"], _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal("error", root.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, root.GetProperty("error_code").GetString()); + Assert.Equal("usage", root.GetProperty("category").GetString()); + Assert.Contains("Unknown --fields value 'bogus'", root.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains($"{command} --fields list", root.GetProperty("hint").GetString(), StringComparison.Ordinal); + Assert.False(root.TryGetProperty("results", out _)); + } + + [Fact] + public void ProjectionFields_AreCaseSensitiveAndDiscoveryValueMustBeUsedAlone_Issue4836() + { + var (caseExitCode, caseStdout, caseStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["references", "--fields", "Path", "--json"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, caseExitCode); + Assert.Equal(string.Empty, caseStderr); + using (var caseDocument = JsonDocument.Parse(caseStdout)) + { + Assert.Contains( + "Unknown --fields value 'Path'", + caseDocument.RootElement.GetProperty("message").GetString(), + StringComparison.Ordinal); + } + + var (typoExitCode, typoStdout, typoStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["search", "--fields", "paht"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, typoExitCode); + Assert.Equal(string.Empty, typoStderr); + using (var typoDocument = JsonDocument.Parse(typoStdout)) + { + Assert.Contains( + "Nearby valid fields: path", + typoDocument.RootElement.GetProperty("hint").GetString(), + StringComparison.Ordinal); + } + + var (listExitCode, listStdout, listStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["search", "--fields", "list,path", "--json"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, listExitCode); + Assert.Equal(string.Empty, listStderr); + using var listDocument = JsonDocument.Parse(listStdout); + Assert.Contains( + "must be used by itself", + listDocument.RootElement.GetProperty("message").GetString(), + StringComparison.Ordinal); + } + + [Fact] + public void ValidMultipleFieldsAndPathAlias_PreserveProjectionBehavior_Issue4836() + { + var projectRoot = TestProjectHelper.CreateTempProject("projection_field_registry_4836"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Alpha.cs", + "csharp", + "public sealed class Alpha { public void Run() { } }"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["search", "Alpha", "--db", dbPath, "--fields", "file,line", "--json"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var row = Assert.Single(document.RootElement.GetProperty("results").EnumerateArray()); + Assert.Equal("src/Alpha.cs", row.GetProperty("file").GetString()); + Assert.True(row.GetProperty("line").GetInt32() > 0); + Assert.Equal(2, row.EnumerateObject().Count()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RegistryAliasesAndNestedCollections_AreMachineDiscoverable_Issue4836() + { + var search = ProjectionFieldRegistry.CreateDiscoveryDocument("search"); + var fileAlias = Assert.Single( + search["fields"]!.AsArray(), + item => item!["name"]!.GetValue() == "file"); + Assert.Equal("alias", fileAlias!["kind"]!.GetValue()); + Assert.Equal("path", fileAlias["alias_for"]!.GetValue()); + + var map = ProjectionFieldRegistry.CreateDiscoveryDocument("map"); + var nestedAlias = Assert.Single( + map["fields"]!.AsArray(), + item => item!["name"]!.GetValue() == "top_files.file"); + Assert.Equal("top_files.path", nestedAlias!["alias_for"]!.GetValue()); + Assert.Equal("top_files", nestedAlias["collection"]!.GetValue()); + } + + [Fact] + public void EveryDiscoveredProjectionField_ValidatesFromTheSameRegistry_Issue4836() + { + foreach (var command in ProjectionFieldRegistry.SupportedCommands) + { + var document = ProjectionFieldRegistry.CreateDiscoveryDocument(command); + var validFields = document["valid_fields"]!.AsArray() + .Select(field => field!.GetValue()) + .ToArray(); + + Assert.NotEmpty(validFields); + Assert.Equal(validFields.Length, validFields.Distinct(StringComparer.Ordinal).Count()); + foreach (var field in validFields) + Assert.True(ProjectionFieldRegistry.TryValidate(command, [field], out var error), error?.Message); + } + } + + [Theory] + [InlineData("search", "guard_evidence")] + [InlineData("search", "next_steps")] + [InlineData("definition", "body_content_recovery")] + [InlineData("symbols", "reference_count")] + [InlineData("symbols", "signature_truncated")] + [InlineData("hotspots", "symbol_count")] + [InlineData("hotspots", "definition_site_details")] + [InlineData("status", "index_matches_workspace")] + [InlineData("status", "effective_config")] + [InlineData("status", "update_check")] + [InlineData("references", "body_content")] + [InlineData("callers", "aggregate_truncated")] + [InlineData("callers", "first_column")] + [InlineData("callees", "body_content_recovery")] + [InlineData("impact", "path_details")] + [InlineData("map", "language_count")] + [InlineData("map", "module_count")] + [InlineData("map", "entrypoint_count")] + [InlineData("map", "summary_only")] + [InlineData("map", "sections")] + [InlineData("map", "output_byte_limit")] + [InlineData("map", "next_commands")] + public void ExistingConditionalAndModeSpecificFields_RemainValid_Issue4836( + string command, + string field) + { + Assert.True( + ProjectionFieldRegistry.TryValidate(command, [field], out var error), + error?.Message); + + var discovery = ProjectionFieldRegistry.CreateDiscoveryDocument(command); + Assert.Contains( + discovery["valid_fields"]!.AsArray(), + item => item!.GetValue() == field); + } + + [Theory] + [InlineData("definition", "container_qualified_name")] + [InlineData("definition", "family_key")] + [InlineData("definition", "is_metadata_target")] + [InlineData("definition", "metadata_target_source")] + [InlineData("definition", "same_line_signature_occurrence_index")] + [InlineData("definition", "reference_count")] + [InlineData("symbols", "container_qualified_name")] + [InlineData("symbols", "family_key")] + [InlineData("symbols", "is_metadata_target")] + [InlineData("symbols", "metadata_target_source")] + [InlineData("symbols", "same_line_signature_occurrence_index")] + [InlineData("callees", "first_column")] + [InlineData("callees", "has_self_reference")] + [InlineData("callees", "has_mutual_recursion")] + public void NonOutputFields_AreNotAdvertisedOrAccepted_Issue4836( + string command, + string field) + { + Assert.False(ProjectionFieldRegistry.TryValidate(command, [field], out var error)); + Assert.NotNull(error); + + var discovery = ProjectionFieldRegistry.CreateDiscoveryDocument(command); + Assert.DoesNotContain( + discovery["valid_fields"]!.AsArray(), + item => item!.GetValue() == field); + } + + [Fact] + public void CallGraphCompactDefaults_AreCommandSpecific_Issue4836() + { + Assert.Contains("column", ProjectionFieldRegistry.GetCompactFields("callers")!); + Assert.DoesNotContain("column", ProjectionFieldRegistry.GetCompactFields("callees")!); + } + + [Fact] + public void MapCollectionCounts_ArePopulatedWhenProjected_Issue4836() + { + var projectRoot = TestProjectHelper.CreateTempProject("projection_map_counts_4836"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Alpha.cs", + "csharp", + "public sealed class Alpha { public static void Main() { } }"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "map", "--db", dbPath, "--fields", + "language_count,module_count,entrypoint_count", "--json", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var row = Assert.Single(document.RootElement.GetProperty("results").EnumerateArray()); + Assert.True(row.TryGetProperty("language_count", out _)); + Assert.True(row.TryGetProperty("module_count", out _)); + Assert.True(row.TryGetProperty("entrypoint_count", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("list")] + [InlineData("bogus")] + public void EarlyProjectionRegistryResponses_HonorMaxJsonBytes_Issue4836(string fields) + { + const int maxJsonBytes = 100; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["status", "--fields", fields, "--max-json-bytes", maxJsonBytes.ToString()], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.True(Encoding.UTF8.GetByteCount(stdout) <= maxJsonBytes); + Assert.Equal(string.Empty, stdout); + Assert.Contains( + $"--max-json-bytes {maxJsonBytes} is too small", + stderr, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("search")] + [InlineData("references")] + [InlineData("map")] + public void CommandHelp_DirectsFieldsUsersToRegistryDiscovery_Issue4836(string command) + { + var (printed, stdout, stderr) = ConsoleCapture.Capture(() => + ConsoleUi.PrintCommandUsage(command) ? 1 : 0); + + Assert.Equal(1, printed); + Assert.Equal(string.Empty, stderr); + Assert.Contains("--fields ", stdout, StringComparison.Ordinal); + Assert.Contains("use --fields list for the machine-readable catalog", stdout, StringComparison.Ordinal); + } +}