diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 5784332c9..a369586a8 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -138,6 +138,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `graph_data_current`, `index_complete`, `index_incomplete_reasons`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `head_freshness`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `git_executable`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, WAL checkpoint diagnostics (`read_only_fallback`, `wal_checkpoint_attempted`, `wal_checkpoint_succeeded`, `wal_checkpoint_skipped_reason`, `wal_checkpoint_failure_reason`, `wal_checkpoint_busy`, `wal_checkpoint_log_page_count`, `wal_checkpoint_checkpointed_page_count`, `wal_checkpoint_remaining_page_count`, `read_only_immutable_fallback`, `wal_stale_snapshot_risk`, `wal_stale_snapshot_reason`), `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. - `maintenance_guidance.fts_optimization` is the shared, read-only recommendation contract for status, explain, optimize preview, and optimize execution. Keep `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state` synchronized; stale or unavailable snapshots must not recommend mutation. - A valid CLI `status --stale-after ` implies the workspace check. Check-mode JSON includes `query_context.check_mode` (`explicit` or `implied_by_stale_after`) and `query_context.stale_after_seconds`; ordinary status JSON omits `query_context`. +- `status --explain` derives accepted top-level keys from the same source-generated `StatusResult` serializer metadata as `status --json`, excludes ignored properties, and supports bounded dot-separated member paths without reading runtime values. Major readiness, trust, extension, maintenance, and cap-hit sections return structured meaning, source, dependencies, interpretation, and repair guidance; unknown input is sanitized and returns bounded valid candidates. Bounded status explain envelopes also omit database paths, timings, indexed HEADs, and stable-at timestamps. - `database_size_attribution` is part of the synchronized status contract. Preserve its read-only main/WAL/SHM separation; exact logical reconciliation across object, freelist, and unexplained-residual bytes; table/index and page-type subtotals; 20-object/128-character sanitized bounds; and explicit `available=false` / stable `unavailable_reason` behavior without zero-valued unavailable object metrics. - Explicit WAL truncate-checkpoint diagnostics must preserve SQLite's `(busy, log, checkpointed)` result, treat non-zero `busy` or positive remaining pages as unsuccessful with bounded machine reasons, accept `(0, -1, -1)` as the successful non-WAL no-op, and never expose raw exception text or paths. - When any readiness field is degraded, the CLI adds `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, and `readiness_degradations[]`. `degraded_root_cause` is the primary stable machine code; `readiness_degradations[]` lists every degraded field with `root_cause`, human reason, and remediation strings. diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a10bb1476..21a487f60 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1756,6 +1756,21 @@ access. | MCP-only session diagnostics | `mcp_session`, `mcp_session.metrics`, `mcp_session.audit_log`, `mcp.rate_limit.bucket_limit`, and `mcp.rate_limit.bucket_limit_rejection_count`. `mcp_session` is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, bounded optional `client_capabilities`, an always-present `metrics` object, and `audit_log` when audit emission is enabled. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `mcp_session.metrics` is `{"enabled":false}` when unconfigured. An enabled metrics sink contains `enabled`, `path`, `max_bytes`, `bytes_written`, `disposed`, `degraded`, `queue_capacity`, `queue_depth`, `queued_event_count`, `written_event_count`, `dropped_event_count`, `queue_full_drop_count`, `serialization_failure_count`, `write_failure_count`, `rotation_failure_count`, `batch_flush_count`, `consecutive_failure_count`, and `recovery_count`, plus optional `next_retry_at`, `last_recovery_at`, and `last_failure`. MCP ping always mirrors the metrics object as `metrics`; metrics degradation is intentionally excluded from its top-level liveness result. The audit status fields and their health semantics are defined in [MCP audit log emission](#mcp-audit-log-emission). `mcp.rate_limit.bucket_limit` is the configured process-local cap across normalized `(partition, caller)` buckets: every direct call uses one fixed caller-wide coarse partition, canonical known tools additionally use secondary per-tool partitions, and unknown `batch_query` slots share one fixed invalid-slot partition per caller. `mcp.rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. | | Documentation sync | Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. | +`status --explain` resolves top-level keys through the source-generated +`StatusResult` `JsonTypeInfo` used by `status --json`; ignored properties are +excluded, and a coverage test requires every serialized top-level property to +produce an explanation. Explicit registry metadata supplies useful meaning, +source, dependencies, interpretation, and repair guidance for major readiness, +trust, extension, maintenance, and cap-hit sections. Other serialized scalar +fields receive a bounded contract explanation instead of becoming unknown as +the DTO evolves. Dotted paths resolve against the same source-generated nested +metadata (including collection element DTOs), while unknown paths receive +bounded valid candidates. Explain responses contain static contract metadata +only, cap known fields and dependencies, sanitize unknown input, and never +include runtime field values or paths. Bounded `--fields` / +`--max-json-bytes` envelopes also omit database paths, timings, indexed HEADs, +and stable-at timestamps. + `head_freshness` is a compact summary for machine consumers. `state=fresh` requires a successful complete `status --check` workspace comparison, `state=fresh_but_incomplete` separates matching-workspace freshness from failed-file coverage, and `state=head_current` @@ -5169,6 +5184,19 @@ help はすべてこのレジストリを参照します。field 名は大文字 | MCP-only session diagnostics | `mcp_session`、`mcp_session.metrics`、`mcp_session.audit_log`、`mcp.rate_limit.bucket_limit`、`mcp.rate_limit.bucket_limit_rejection_count`。`mcp_session` は persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities`、常設の `metrics` object、audit 出力が有効な場合の `audit_log` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。未設定時の `mcp_session.metrics` は `{"enabled":false}` です。有効な metrics sink は `enabled`、`path`、`max_bytes`、`bytes_written`、`disposed`、`degraded`、`queue_capacity`、`queue_depth`、`queued_event_count`、`written_event_count`、`dropped_event_count`、`queue_full_drop_count`、`serialization_failure_count`、`write_failure_count`、`rotation_failure_count`、`batch_flush_count`、`consecutive_failure_count`、`recovery_count` に加え、任意の `next_retry_at`、`last_recovery_at`、`last_failure` を追加します。MCP ping は常に metrics object を `metrics` として返し、metrics の degradation は意図的に top-level liveness result へ反映しません。audit status field と health semantics は [MCP 監査ログの出力](#mcp-監査ログの出力) に定義します。`mcp.rate_limit.bucket_limit` は normalized な `(partition, caller)` bucket 全体に対する process-local 上限で、direct call はすべて caller-wide の固定 coarse partition、canonical な既知 tool は追加の secondary per-tool partition、unknown な `batch_query` slot は caller ごとの 1 つの固定 invalid-slot partition を使います。`mcp.rate_limit.bucket_limit_rejection_count` は新規 bucket 作成がその上限を超えるため拒否された呼び出し数です。 | | documentation sync | この一覧は `README.md` と `AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 | +`status --explain` の top-level key は `status --json` と同じ source-generated +`StatusResult` `JsonTypeInfo` で解決します。ignored property は除外し、coverage test で +serialized top-level property がすべて説明を返すことを固定します。主要な readiness、 +trust、extension、maintenance、cap-hit section には、明示的な registry metadata として +meaning、source、dependencies、interpretation、repair guidance を付けます。それ以外の +serialized scalar field も、DTO 拡張時に unknown へ戻らず上限付き contract explanation を返します。 +dot 区切り path は collection element DTO を含む同じ source-generated nested metadata で解決し、 +unknown path には上限付きの有効な candidate を返します。explain response は static contract +metadata だけを含み、known field と dependency の件数を制限し、unknown input を sanitize し、 +runtime field value や path を含めません。bounded `--fields` / +`--max-json-bytes` envelope も database path、timing、indexed HEAD、 +stable-at timestamp を省略します。 + `head_freshness` は machine consumer 向けの compact summary です。 `state=fresh` は complete な index に対する `status --check` の workspace 比較成功が必要で、 `state=fresh_but_incomplete` は workspace freshness と failed-file coverage を分離し、 diff --git a/README.md b/README.md index c4dfa5061..f91867810 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,17 @@ fields, including readiness fields and runtime diagnostics such as `path_case_sensitive`. `cdidx status --explain sqlite_connection_policy` describes the active SQLite open mode, immutable-URI choice, timeout, cancellation, and WAL snapshot-risk diagnostics. -`cdidx status --explain maintenance_guidance` describes the shared FTS +Accepted top-level explain keys come from the same source-generated +`StatusResult` serializer registry as `status --json`, so every serialized +top-level field remains explainable as the response evolves. Major composite +sections such as `trust_overrides`, `extractors`, `hooks`, +`maintenance_guidance`, and `reference_extraction_cap_hits` add structured +meaning, source, dependencies, interpretation, and repair guidance. Dotted +member paths such as `maintenance_guidance.recommended_command` are also +accepted; explain output is bounded contract metadata and never includes live +runtime values or paths. Bounded `--fields` / `--max-json-bytes` envelopes also +omit database paths, timings, indexed HEADs, and stable-at timestamps. +`cdidx status --explain maintenance_guidance` also describes the shared FTS optimization recommendation used by status and optimize. | Field group | Fields | @@ -691,7 +701,16 @@ visible な status field の簡潔な説明は `cdidx status --explain ` readiness field に加えて、`path_case_sensitive` などの runtime diagnostic field も対象です。 `cdidx status --explain sqlite_connection_policy` は、有効な SQLite open mode、 immutable URI の選択、timeout、cancellation、WAL snapshot risk の diagnostic を説明します。 -`cdidx status --explain maintenance_guidance` は、status と optimize が共有する +top-level の explain key は `status --json` と同じ source-generated +`StatusResult` serializer registry から生成されるため、response が拡張されても serialized +top-level field はすべて説明可能な状態を維持します。`trust_overrides`、`extractors`、 +`hooks`、`maintenance_guidance`、`reference_extraction_cap_hits` などの主要な composite +section は、meaning、source、dependencies、interpretation、repair guidance を構造化して返します。 +`maintenance_guidance.recommended_command` のような dot 区切り member path も受理します。 +explain output は上限付きの contract metadata だけで、live runtime value や path は含みません。 +bounded `--fields` / `--max-json-bytes` envelope も database path、timing、 +indexed HEAD、stable-at timestamp を省略します。 +`cdidx status --explain maintenance_guidance` はさらに、status と optimize が共有する FTS optimization recommendation を説明します。 | field group | fields | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 699a6e84f..00a897620 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -190,6 +190,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. + Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. @@ -1132,6 +1133,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 + status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 diff --git a/changelog.d/unreleased/4891.fixed.md b/changelog.d/unreleased/4891.fixed.md new file mode 100644 index 000000000..a37f10d78 --- /dev/null +++ b/changelog.d/unreleased/4891.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 4891 +affected: + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - src/CodeIndex/Cli/JsonEnvelopeWrapper.cs + - src/CodeIndex/Cli/ProjectionFieldRegistry.cs + - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs + - tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md + - AGENT_GUIDE.md +--- + +## English + +- **`status --explain` now covers every serialized top-level status field and major nested sections (#4891)** — accepted keys now come from the same source-generated serializer metadata as `status --json`; major readiness, trust, extractor, hook, maintenance, and reference-cap sections return structured meaning, source, dependencies, interpretation, and repair guidance, while dotted member paths, unknown-key candidates, and bounded envelope metadata remain bounded and runtime-value-free. + +## 日本語 + +- **`status --explain` が serialized top-level status field と主要 nested section をすべて説明するようになりました (#4891)** — 受理 key は `status --json` と同じ source-generated serializer metadata から生成されます。主要な readiness、trust、extractor、hook、maintenance、reference cap section は meaning、source、dependencies、interpretation、repair guidance を構造化して返し、dot 区切り member path、unknown key candidate、bounded envelope metadata は上限付きで runtime value を含みません。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index f0f3cd9ba..dcd3afbaa 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -170,6 +170,10 @@ private static bool HasCompactOutputSelection(string[] args) return false; } + private static bool IsStaticStatusExplainRequest(string command, string[] args) + => string.Equals(command, "status", StringComparison.Ordinal) + && HasArgument(args, "--explain"); + private static int RunBoundedResponse( string command, string[] args, @@ -218,7 +222,10 @@ private static int RunBoundedResponse( var queryNormalized = ExtractQueryArg(args); var (resolvedDbPath, dbPathExplicit) = ResolveQueryDbPath(args); var queryFingerprint = BuildResponseFingerprint(command, args); - var snapshot = SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion); + var suppressRuntimeMetadata = IsStaticStatusExplainRequest(command, args); + var snapshot = suppressRuntimeMetadata + ? BuildFallbackResponseSnapshot(appVersion) + : SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion); if (controls.CursorQueryFingerprint is not null && !string.Equals(controls.CursorQueryFingerprint, queryFingerprint, StringComparison.Ordinal)) { @@ -287,7 +294,8 @@ private static int RunBoundedResponse( exitCode, message, "Reduce --limit, choose fewer --fields, or use a narrower query.", - controls.MaxJsonBytes); + controls.MaxJsonBytes, + suppressRuntimeMetadata); } JsonArray rawResults; @@ -299,11 +307,11 @@ private static int RunBoundedResponse( } catch (JsonEnvelopeRawJsonItemLimitExceededException ex) { - return WriteBoundedParseError(command, queryNormalized, resolvedDbPath, dbPathExplicit, appVersion, stopwatch.Elapsed.TotalMilliseconds, jsonOptions, $"Bounded response raw JSON item line exceeded {ex.MaxChars} characters.", "Reduce --limit or exclude large detail fields.", "max_chars", ex.MaxChars, controls.MaxJsonBytes); + return WriteBoundedParseError(command, queryNormalized, resolvedDbPath, dbPathExplicit, appVersion, stopwatch.Elapsed.TotalMilliseconds, jsonOptions, $"Bounded response raw JSON item line exceeded {ex.MaxChars} characters.", "Reduce --limit or exclude large detail fields.", "max_chars", ex.MaxChars, controls.MaxJsonBytes, suppressRuntimeMetadata); } catch (JsonEnvelopeRawJsonBudgetExceededException ex) { - return WriteBoundedParseError(command, queryNormalized, resolvedDbPath, dbPathExplicit, appVersion, stopwatch.Elapsed.TotalMilliseconds, jsonOptions, $"Bounded response raw JSON {ex.BudgetName} exceeded {ex.MaxValue}.", "Reduce --limit or narrow the query.", ex.JsonPropertyName, ex.MaxValue, controls.MaxJsonBytes); + return WriteBoundedParseError(command, queryNormalized, resolvedDbPath, dbPathExplicit, appVersion, stopwatch.Elapsed.TotalMilliseconds, jsonOptions, $"Bounded response raw JSON {ex.BudgetName} exceeded {ex.MaxValue}.", "Reduce --limit or narrow the query.", ex.JsonPropertyName, ex.MaxValue, controls.MaxJsonBytes, suppressRuntimeMetadata); } var commandError = TakeCommandError(rawResults, exitCode); @@ -330,7 +338,9 @@ private static int RunBoundedResponse( 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); + var completedSnapshot = suppressRuntimeMetadata + ? snapshot + : SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion); if (!string.Equals(snapshot.GenerationFingerprint, completedSnapshot.GenerationFingerprint, StringComparison.Ordinal)) { return WriteBoundedResponseUsageError( @@ -356,6 +366,7 @@ private static int RunBoundedResponse( commandError, streamTerminal, streamControlRecords, + suppressRuntimeMetadata, jsonOptions, out var emittedJson, out var emittedCount); @@ -427,6 +438,7 @@ private static int RunOutlineValidationWithinBudget( JsonObject? commandError, JsonObject? streamTerminal, JsonArray streamControlRecords, + bool suppressRuntimeMetadata, JsonSerializerOptions jsonOptions, out string emittedJson, out int emittedCount) @@ -454,9 +466,11 @@ JsonObject BuildCandidate(int count) error: commandError is null ? null : (JsonObject)commandError.DeepClone(), streamTerminal: adjustedStreamTerminal, streamControlRecords: streamControlRecords, - responseSnapshot: snapshot); + responseSnapshot: snapshot, + suppressRuntimeMetadata: suppressRuntimeMetadata); var metadata = (JsonObject)envelope["metadata"]!; - metadata["result_stable_at"] = snapshot.ResultStableAt; + if (!suppressRuntimeMetadata) + metadata["result_stable_at"] = snapshot.ResultStableAt; if (commandError is not null) { metadata["returned_count"] = 0; @@ -1603,8 +1617,9 @@ private static int WriteBoundedCaptureError( int exitCode, string message, string hint, - int? maxJsonBytes) - => WriteBoundedErrorEnvelope(command, queryNormalized, dbPath, dbPathExplicit, appVersion, elapsedMs, jsonOptions, exitCode, message, hint, null, null, maxJsonBytes); + int? maxJsonBytes, + bool suppressRuntimeMetadata) + => WriteBoundedErrorEnvelope(command, queryNormalized, dbPath, dbPathExplicit, appVersion, elapsedMs, jsonOptions, exitCode, message, hint, null, null, maxJsonBytes, suppressRuntimeMetadata); private static int WriteBoundedParseError( string command, @@ -1618,8 +1633,9 @@ private static int WriteBoundedParseError( string hint, string budgetProperty, int budgetValue, - int? maxJsonBytes) - => WriteBoundedErrorEnvelope(command, queryNormalized, dbPath, dbPathExplicit, appVersion, elapsedMs, jsonOptions, CommandExitCodes.InvalidArgument, message, hint, budgetProperty, budgetValue, maxJsonBytes); + int? maxJsonBytes, + bool suppressRuntimeMetadata) + => WriteBoundedErrorEnvelope(command, queryNormalized, dbPath, dbPathExplicit, appVersion, elapsedMs, jsonOptions, CommandExitCodes.InvalidArgument, message, hint, budgetProperty, budgetValue, maxJsonBytes, suppressRuntimeMetadata); private static int WriteBoundedErrorEnvelope( string command, @@ -1634,7 +1650,8 @@ private static int WriteBoundedErrorEnvelope( string hint, string? budgetProperty, int? budgetValue, - int? maxJsonBytes) + int? maxJsonBytes, + bool suppressRuntimeMetadata) { CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: {message}"); CommandErrorWriter.WriteStderr($"Hint: {hint}"); @@ -1646,7 +1663,17 @@ private static int WriteBoundedErrorEnvelope( }; if (budgetProperty is not null) error[budgetProperty] = budgetValue; - var envelope = BuildEnvelope(command, queryNormalized, dbPath, dbPathExplicit, appVersion, elapsedMs, [], exitCode, error); + var envelope = BuildEnvelope( + command, + queryNormalized, + dbPath, + dbPathExplicit, + appVersion, + elapsedMs, + [], + exitCode, + error, + suppressRuntimeMetadata: suppressRuntimeMetadata); var json = envelope.ToJsonString(jsonOptions); if (!maxJsonBytes.HasValue || JsonFitsResponseBudget(json, maxJsonBytes.Value)) Console.WriteLine(json); diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs index 43eb05444..43ee62ad6 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs @@ -279,18 +279,22 @@ private static JsonObject BuildEnvelope( JsonObject? error = null, JsonObject? streamTerminal = null, JsonArray? streamControlRecords = null, - ResponseSnapshot? responseSnapshot = null) + ResponseSnapshot? responseSnapshot = null, + bool suppressRuntimeMetadata = false) { var metadata = new JsonObject { ["api_version"] = JsonOutputContract.ApiVersion, ["command"] = command, - ["cdidx_version"] = appVersion, - ["elapsed_ms"] = Math.Round(elapsedMs, 3), - ["db_path"] = dbPath, ["result_count"] = results.Count, ["exit_code"] = exitCode, }; + if (!suppressRuntimeMetadata) + { + metadata["cdidx_version"] = appVersion; + metadata["elapsed_ms"] = Math.Round(elapsedMs, 3); + metadata["db_path"] = dbPath; + } if (!string.IsNullOrEmpty(queryNormalized)) metadata["query_normalized"] = queryNormalized; @@ -301,11 +305,14 @@ private static JsonObject BuildEnvelope( if (streamControlRecords is { Count: > 0 }) metadata["stream_control_records"] = streamControlRecords.DeepClone(); - var indexedHead = responseSnapshot.HasValue - ? responseSnapshot.Value.IndexedHead - : SafeReadIndexedHead(dbPath, dbPathExplicit); - if (!string.IsNullOrEmpty(indexedHead)) - metadata["indexed_at_head_sha"] = indexedHead; + if (!suppressRuntimeMetadata) + { + var indexedHead = responseSnapshot.HasValue + ? responseSnapshot.Value.IndexedHead + : SafeReadIndexedHead(dbPath, dbPathExplicit); + if (!string.IsNullOrEmpty(indexedHead)) + metadata["indexed_at_head_sha"] = indexedHead; + } return new JsonObject { diff --git a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs index f4666f6c8..2c1e7bfac 100644 --- a/src/CodeIndex/Cli/ProjectionFieldRegistry.cs +++ b/src/CodeIndex/Cli/ProjectionFieldRegistry.cs @@ -205,7 +205,9 @@ private static ProjectionCommandFieldSchema CreateStatusSchema() .Fields(GetJsonFieldNames()) .Fields( "effective_config", "log_path", "field", "label", "ready", "degraded", - "remediation", "known_fields")); + "remediation", "known_fields", "scope", "meaning", "source", "dependencies", + "dependencies_truncated", "interpretation", "repair_guidance", "redaction", + "known_field_limit", "known_fields_truncated")); private static ProjectionCommandFieldSchema CreateHotspotsSchema() => Create( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 919e807c4..bc96c3bb5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using CodeIndex.Database; using CodeIndex.Diagnostics; using CodeIndex.Indexer; @@ -94,7 +95,7 @@ public static int RunStatus( } if (options.Json) return WriteStatusReadinessExplanationJson(options.StatusExplainField, jsonOptions); - return WriteStatusReadinessExplanation(options.StatusExplainField); + return WriteStatusReadinessExplanation(options.StatusExplainField, jsonOptions); } return WithDb(options, jsonOptions, reader => @@ -386,18 +387,36 @@ public static int RunStatus( }, cancellationToken: cancellationToken); } - private static int WriteStatusReadinessExplanation(string fieldName) + private const int MaxStatusExplainInputLength = 240; + private const int MaxStatusExplainPathDepth = 4; + private const int MaxStatusExplainKnownFields = 128; + private const int MaxStatusExplainDependencies = 16; + private const int MaxStatusExplainTextLength = 1024; + + private sealed record StatusJsonPathResolution( + string CanonicalPath, + JsonPropertyInfo TopLevelProperty, + JsonPropertyInfo LeafProperty); + + private static int WriteStatusReadinessExplanation(string fieldName, JsonSerializerOptions jsonOptions) { - var field = FindStatusFieldExplanation(fieldName); + var field = FindStatusFieldExplanation(fieldName, jsonOptions); if (field == null) { - CommandErrorWriter.WriteStderr($"Error: unknown status field `{fieldName}`."); - CommandErrorWriter.WriteStderr($"Hint: use one of: {string.Join(", ", StatusExplainFields.Select(f => f.FieldName))}."); + var safeFieldName = SanitizeStatusExplainInput(fieldName); + CommandErrorWriter.WriteStderr($"Error: unknown status field `{safeFieldName}`."); + CommandErrorWriter.WriteStderr($"Hint: {BuildStatusExplainCandidateHint(fieldName, jsonOptions)}"); return CommandExitCodes.UsageError; } Console.WriteLine($"{field.Label} ({field.FieldName})"); Console.WriteLine(); + Console.WriteLine($"Meaning: {BoundStatusExplainText(field.EffectiveMeaning)}"); + Console.WriteLine($"Source: {BoundStatusExplainText(field.EffectiveSource)}"); + Console.WriteLine($"Dependencies: {FormatStatusExplainDependencies(field.EffectiveDependencies)}"); + Console.WriteLine($"Interpretation: {BoundStatusExplainText(field.EffectiveInterpretation)}"); + Console.WriteLine($"Repair guidance: {BoundStatusExplainText(field.Remediation)}"); + Console.WriteLine(); Console.WriteLine($"Ready: {field.ReadyText}"); Console.WriteLine($"Degraded: {field.DegradedText}"); Console.WriteLine($"Remediation: {field.Remediation}"); @@ -406,39 +425,370 @@ private static int WriteStatusReadinessExplanation(string fieldName) private static int WriteStatusReadinessExplanationJson(string fieldName, JsonSerializerOptions jsonOptions) { - var field = FindStatusFieldExplanation(fieldName); + var field = FindStatusFieldExplanation(fieldName, jsonOptions); if (field == null) + { + var safeFieldName = SanitizeStatusExplainInput(fieldName); return CommandErrorWriter.WriteJsonOrHuman( true, jsonOptions, - $"unknown status field `{fieldName}`.", + $"unknown status field `{safeFieldName}`.", CommandExitCodes.UsageError, - $"use one of: {string.Join(", ", StatusExplainFields.Select(f => f.FieldName))}.", + BuildStatusExplainCandidateHint(fieldName, jsonOptions), errorCode: CommandErrorCodes.UsageError, category: "usage"); + } + var knownFieldNames = GetStatusExplainKnownFieldNames(jsonOptions, out var knownFieldsTruncated); var knownFields = new JsonArray(); - foreach (var knownField in StatusExplainFields) - knownFields.Add(knownField.FieldName); + foreach (var knownField in knownFieldNames) + knownFields.Add(knownField); + var dependencies = new JsonArray(); + foreach (var dependency in field.EffectiveDependencies.Take(MaxStatusExplainDependencies)) + dependencies.Add(dependency); var payload = new JsonObject { ["api_version"] = JsonOutputContract.ApiVersion, ["field"] = field.FieldName, ["label"] = field.Label, + ["scope"] = field.FieldName.Contains('.') ? "member" : "top_level", + ["meaning"] = BoundStatusExplainText(field.EffectiveMeaning), + ["source"] = BoundStatusExplainText(field.EffectiveSource), + ["dependencies"] = dependencies, + ["dependencies_truncated"] = field.EffectiveDependencies.Count > MaxStatusExplainDependencies, + ["interpretation"] = BoundStatusExplainText(field.EffectiveInterpretation), + ["repair_guidance"] = BoundStatusExplainText(field.Remediation), ["ready"] = field.ReadyText, ["degraded"] = field.DegradedText, ["remediation"] = field.Remediation, + ["redaction"] = new JsonObject + { + ["runtime_values_included"] = false, + ["paths_included"] = false, + }, ["known_fields"] = knownFields, + ["known_field_limit"] = MaxStatusExplainKnownFields, + ["known_fields_truncated"] = knownFieldsTruncated, }; CommandOutputWriter.WriteJsonNode(payload, jsonOptions); return CommandExitCodes.Success; } - private static StatusFieldExplanation? FindStatusFieldExplanation(string fieldName) - => StatusExplainFields.FirstOrDefault( - field => string.Equals(field.FieldName, fieldName, StringComparison.OrdinalIgnoreCase) - || string.Equals(field.Label, fieldName, StringComparison.OrdinalIgnoreCase)); + private static StatusFieldExplanation? FindStatusFieldExplanation( + string fieldName, + JsonSerializerOptions jsonOptions) + { + var requestedName = fieldName.Trim(); + var labelMatch = StatusExplainFields.FirstOrDefault( + field => string.Equals(field.Label, requestedName, StringComparison.OrdinalIgnoreCase)); + if (labelMatch != null) + requestedName = labelMatch.FieldName; + + if (!TryResolveStatusJsonPath(requestedName, jsonOptions, out var resolution)) + return null; + + var explicitMatch = StatusExplainFields.FirstOrDefault( + field => string.Equals(field.FieldName, resolution.CanonicalPath, StringComparison.OrdinalIgnoreCase)); + return explicitMatch ?? BuildGeneratedStatusFieldExplanation(resolution); + } + + internal static IReadOnlyList GetStatusSerializableFieldNames(JsonSerializerOptions jsonOptions) + => CliJsonSerializerContextFactory.Create(jsonOptions) + .StatusResult + .Properties + .Where(property => property.Get != null) + .Select(property => property.Name) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static IReadOnlyList GetStatusExplainKnownFieldNames( + JsonSerializerOptions jsonOptions, + out bool truncated) + { + var serializerFields = GetStatusSerializableFieldNames(jsonOptions); + var result = new List(Math.Min(MaxStatusExplainKnownFields, serializerFields.Count)); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var fieldName in serializerFields) + { + if (result.Count == MaxStatusExplainKnownFields) + break; + result.Add(fieldName); + seen.Add(fieldName); + } + + foreach (var field in StatusMemberExplainFields) + { + if (result.Count == MaxStatusExplainKnownFields) + break; + if (TryResolveStatusJsonPath(field.FieldName, jsonOptions, out _) + && seen.Add(field.FieldName)) + { + result.Add(field.FieldName); + } + } + + truncated = false; + foreach (var memberPath in EnumerateStatusExplainNestedPaths(jsonOptions)) + { + if (!seen.Add(memberPath)) + continue; + if (result.Count == MaxStatusExplainKnownFields) + { + truncated = true; + break; + } + result.Add(memberPath); + } + return result; + } + + private static IEnumerable EnumerateStatusExplainNestedPaths( + JsonSerializerOptions jsonOptions, + string? topLevelFilter = null) + { + var context = CliJsonSerializerContextFactory.Create(jsonOptions); + foreach (var topLevelProperty in context.StatusResult.Properties) + { + if (topLevelProperty.Get == null + || topLevelFilter != null + && !string.Equals(topLevelProperty.Name, topLevelFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var nestedType = GetStatusExplainNestedType(topLevelProperty.PropertyType); + if (nestedType == null) + continue; + foreach (var path in EnumerateStatusExplainNestedPaths( + context, + nestedType, + topLevelProperty.Name, + segmentCount: 1)) + { + yield return path; + } + } + } + + private static IEnumerable EnumerateStatusExplainNestedPaths( + CliJsonSerializerContext context, + Type nestedType, + string prefix, + int segmentCount) + { + if (segmentCount >= MaxStatusExplainPathDepth) + yield break; + + var typeInfo = GetStatusExplainTypeInfo(context, nestedType); + if (typeInfo == null) + yield break; + + foreach (var property in typeInfo.Properties) + { + if (property.Get == null) + continue; + + var path = $"{prefix}.{property.Name}"; + yield return path; + + var childType = GetStatusExplainNestedType(property.PropertyType); + if (childType == null) + continue; + foreach (var childPath in EnumerateStatusExplainNestedPaths( + context, + childType, + path, + segmentCount + 1)) + { + yield return childPath; + } + } + } + + private static bool TryResolveStatusJsonPath( + string fieldName, + JsonSerializerOptions jsonOptions, + out StatusJsonPathResolution resolution) + { + resolution = null!; + if (string.IsNullOrWhiteSpace(fieldName) || fieldName.Length > MaxStatusExplainInputLength) + return false; + + var segments = fieldName.Split('.', StringSplitOptions.None); + if (segments.Length == 0 + || segments.Length > MaxStatusExplainPathDepth + || segments.Any(string.IsNullOrWhiteSpace)) + { + return false; + } + + var context = CliJsonSerializerContextFactory.Create(jsonOptions); + JsonTypeInfo? typeInfo = context.StatusResult; + JsonPropertyInfo? topLevelProperty = null; + JsonPropertyInfo? leafProperty = null; + var canonicalSegments = new List(segments.Length); + + foreach (var segment in segments) + { + if (typeInfo == null) + return false; + + leafProperty = typeInfo.Properties.FirstOrDefault( + property => property.Get != null + && string.Equals(property.Name, segment, StringComparison.OrdinalIgnoreCase)); + if (leafProperty == null) + return false; + + topLevelProperty ??= leafProperty; + canonicalSegments.Add(leafProperty.Name); + var nestedType = GetStatusExplainNestedType(leafProperty.PropertyType); + typeInfo = nestedType == null ? null : GetStatusExplainTypeInfo(context, nestedType); + } + + resolution = new StatusJsonPathResolution( + string.Join('.', canonicalSegments), + topLevelProperty!, + leafProperty!); + return true; + } + + private static Type? GetStatusExplainNestedType(Type propertyType) + { + var type = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + if (type == typeof(string)) + return null; + if (type.IsArray) + return type.GetElementType(); + if (!type.IsGenericType) + return type.IsClass ? type : null; + + var genericDefinition = type.GetGenericTypeDefinition(); + var genericArguments = type.GetGenericArguments(); + if (genericDefinition == typeof(Dictionary<,>) + || genericDefinition == typeof(IReadOnlyDictionary<,>) + || genericDefinition == typeof(IDictionary<,>)) + { + return genericArguments[1]; + } + + if (genericDefinition == typeof(List<>) + || genericDefinition == typeof(IReadOnlyList<>) + || genericDefinition == typeof(IList<>) + || genericDefinition == typeof(IEnumerable<>)) + { + return genericArguments[0]; + } + + return type.IsClass ? type : null; + } + + private static JsonTypeInfo? GetStatusExplainTypeInfo( + CliJsonSerializerContext context, + Type type) + { + try + { + return context.GetTypeInfo(type); + } + catch (NotSupportedException) + { + return null; + } + } + + private static StatusFieldExplanation BuildGeneratedStatusFieldExplanation( + StatusJsonPathResolution resolution) + { + var topLevelName = resolution.TopLevelProperty.Name; + var topLevelExplanation = StatusExplainFields.FirstOrDefault( + field => string.Equals(field.FieldName, topLevelName, StringComparison.OrdinalIgnoreCase)); + var isNested = resolution.CanonicalPath.Contains('.'); + if (!isNested) + { + var label = FormatStatusExplainLabel(resolution.CanonicalPath); + return new StatusFieldExplanation( + resolution.CanonicalPath, + label, + "the field is serialized from the current status snapshot according to its documented JSON type and omission rules.", + "an absent nullable field means the value was unavailable, not requested, or unsupported by the current database/platform.", + "Inspect related readiness/degradation fields and rerun `cdidx status --check --json` when freshness or repair guidance is needed.", + Meaning: $"Top-level `{resolution.CanonicalPath}` field in the source-generated status JSON contract.", + Source: "The source-generated `StatusResult` serializer registry and the status reader/runtime enrichers.", + Dependencies: [], + Interpretation: $"Interpret `{resolution.CanonicalPath}` according to its serialized type and alongside the status summary/readiness fields."); + } + + var parent = topLevelExplanation ?? BuildGeneratedStatusFieldExplanation( + new StatusJsonPathResolution( + topLevelName, + resolution.TopLevelProperty, + resolution.TopLevelProperty)); + return new StatusFieldExplanation( + resolution.CanonicalPath, + $"{parent.Label}: {FormatStatusExplainLabel(resolution.LeafProperty.Name)}", + $"the `{resolution.LeafProperty.Name}` member is present in the serialized `{topLevelName}` section.", + $"an omitted nullable member is unavailable or not applicable within `{topLevelName}`.", + parent.Remediation, + Meaning: $"The `{resolution.LeafProperty.Name}` member of the `{topLevelName}` status section.", + Source: parent.EffectiveSource, + Dependencies: [topLevelName], + Interpretation: $"Interpret this member in the context of `{topLevelName}` and its sibling state/count/truncation fields."); + } + + private static string FormatStatusExplainLabel(string fieldName) + { + var words = fieldName.Replace('_', ' '); + return words.Length == 0 + ? "Status field" + : char.ToUpperInvariant(words[0]) + words[1..]; + } + + private static string BuildStatusExplainCandidateHint( + string fieldName, + JsonSerializerOptions jsonOptions) + { + var knownFields = GetStatusExplainKnownFieldNames(jsonOptions, out var truncated); + var requestedTopLevel = fieldName.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + IReadOnlyList candidates = knownFields; + var candidatesTruncated = truncated; + if (requestedTopLevel != null + && TryResolveStatusJsonPath(requestedTopLevel, jsonOptions, out _)) + { + var memberCandidates = EnumerateStatusExplainNestedPaths(jsonOptions, requestedTopLevel) + .Take(MaxStatusExplainKnownFields + 1) + .ToArray(); + if (memberCandidates.Length > 0) + { + candidatesTruncated = memberCandidates.Length > MaxStatusExplainKnownFields; + candidates = memberCandidates.Take(MaxStatusExplainKnownFields).ToArray(); + } + } + + var suffix = candidatesTruncated ? $" (first {MaxStatusExplainKnownFields} candidates)." : "."; + return $"use one of: {string.Join(", ", candidates)}{suffix}"; + } + + private static string SanitizeStatusExplainInput(string value) + => DiagnosticRedactor.RedactSensitiveText( + DiagnosticSanitizer.ForMessage(value, MaxStatusExplainInputLength), + redactPaths: true); + + private static string BoundStatusExplainText(string value) + => value.Length <= MaxStatusExplainTextLength + ? value + : value[..(MaxStatusExplainTextLength - 3)] + "..."; + + private static string FormatStatusExplainDependencies(IReadOnlyList dependencies) + { + if (dependencies.Count == 0) + return "none"; + + var suffix = dependencies.Count > MaxStatusExplainDependencies + ? $" (first {MaxStatusExplainDependencies})" + : string.Empty; + return string.Join(", ", dependencies.Take(MaxStatusExplainDependencies)) + suffix; + } private static void WriteStatusReadinessSummary(StatusResult status, QueryCommandOptions options) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs b/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs index 654b1bb61..1949ae297 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs @@ -9,7 +9,24 @@ private sealed record StatusFieldExplanation( string Label, string ReadyText, string DegradedText, - string Remediation); + string Remediation, + string? Meaning = null, + string? Source = null, + IReadOnlyList? Dependencies = null, + string? Interpretation = null) + { + public string EffectiveMeaning => + Meaning ?? $"{Label} describes the `{FieldName}` status contract."; + + public string EffectiveSource => + Source ?? "The source-generated `StatusResult` JSON contract and the status reader or runtime enricher that populates this field."; + + public IReadOnlyList EffectiveDependencies => + Dependencies ?? Array.Empty(); + + public string EffectiveInterpretation => + Interpretation ?? $"Ready: {ReadyText} Degraded or absent: {DegradedText}"; + } private static readonly StatusFieldExplanation[] StatusReadinessFields = [ @@ -93,9 +110,303 @@ private sealed record StatusFieldExplanation( "Run status with a current cdidx binary, or rebuild the DB with the version you intend to use."), ]; + private static readonly StatusFieldExplanation[] StatusMemberExplainFields = + [ + new( + "reference_extraction_cap_hits.state_available", + "Reference cap-hit state availability", + "`true` means the current index generation persisted cap-hit audit state.", + "`false` means graph absence cannot be treated as authoritative until indexing refreshes the audit state.", + "Run `cdidx index ` with a current binary, then recheck `reference_graph_complete`.", + Meaning: "Identifies whether the cap-hit summary is backed by current persisted audit state.", + Source: "The persisted reference-extraction completeness metadata read with the current index generation.", + Dependencies: ["reference_extraction_cap_hits", "reference_graph_complete"], + Interpretation: "Treat zero cap hits as authoritative only when this member is `true`."), + new( + "reference_extraction_cap_hits.files", + "Reference cap-hit file sample", + "the bounded list identifies files and stable cap reasons that limited reference extraction.", + "an empty list is non-authoritative when `state_available=false`, and `files_truncated=true` means additional affected files were omitted.", + "Inspect the listed files, reduce or exclude pathological/generated input, rerun indexing, and recheck graph readiness.", + Meaning: "A bounded, support-safe sample of files whose reference extraction hit a safety cap.", + Source: "Per-file cap diagnostics persisted by the reference extractor and summarized by the status reader.", + Dependencies: ["reference_extraction_cap_hits.state_available", "reference_extraction_cap_hits.files_truncated", "reference_extraction_cap_hits.file_limit"], + Interpretation: "Use the sample for diagnosis, not as an exhaustive inventory when truncation is reported."), + new( + "maintenance_guidance.wal_state", + "Maintenance WAL state", + "`ok` means WAL bytes are below the configured warning threshold.", + "`checkpoint_recommended` suggests a bounded WAL checkpoint; `unknown` means the size could not be measured.", + "Follow `maintenance_guidance.recommended_command` only after confirming no index writer is active.", + Meaning: "Classifies the current WAL size against the effective maintenance warning threshold.", + Source: "Live database/WAL metrics and `CDIDX_MAINTENANCE_WAL_WARN_BYTES` (or its default).", + Dependencies: ["wal_size_bytes", "maintenance_guidance.wal_threshold_bytes"], + Interpretation: "This is maintenance advice, not a readiness failure by itself."), + new( + "maintenance_guidance.recommended_command", + "Recommended maintenance command", + "`none` means the measured WAL and freelist metrics do not currently recommend maintenance.", + "a command string recommends one operator action derived from the measured WAL/freelist state.", + "Run the command only against the intended database and only when no index writer is active; then follow `post_maintenance_follow_up` when present.", + Meaning: "The single maintenance action selected from the current WAL and freelist classifications.", + Source: "The maintenance guidance builder using live PRAGMA/file metrics and configured thresholds.", + Dependencies: ["maintenance_guidance.wal_state", "maintenance_guidance.freelist_state"], + Interpretation: "Treat the command as operator guidance rather than an automatically executed action."), + new( + "maintenance_guidance.fts_optimization", + "FTS optimization guidance", + "the object reports the shared `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state` decision used by status and optimize.", + "`state=stale` or `state=unavailable` suppresses an optimize recommendation because the persisted write counter or database-page snapshot is not trustworthy.", + "Wait for active indexing to finish and rerun `cdidx status --json`; run `cdidx optimize --dry-run --json` to inspect the same decision before mutation.", + Meaning: "A read-only recommendation for optimizing FTS indexes after enough incremental writes.", + Source: "The shared maintenance guidance builder using the persisted incremental-write counter and current database-page snapshot.", + Dependencies: ["maintenance_guidance.fts_optimization.state", "maintenance_guidance.fts_optimization.observed_writes"], + Interpretation: "Run optimize only when `recommended=true` and the state is current."), + new( + "maintenance_guidance.fts_optimization.state", + "FTS optimization snapshot state", + "`current` means the recommendation uses a trustworthy persisted write counter and database-page snapshot.", + "`stale` or `unavailable` suppresses recommendation even when the observed counter would otherwise reach the threshold.", + "Wait for active indexing to finish and rerun status before acting on a stale or unavailable recommendation.", + Meaning: "Trust state for the shared FTS optimization recommendation.", + Source: "The maintenance snapshot lifecycle and persisted incremental-write counter.", + Dependencies: ["maintenance_guidance.fts_optimization.recommended", "maintenance_guidance.fts_optimization.reason"], + Interpretation: "Treat only `current` decisions as actionable."), + new( + "extractors.diagnostics", + "Extractor diagnostics", + "an absent or empty list means no bounded plugin/pattern discovery diagnostic was emitted.", + "entries report stable categories for skipped, incompatible, malformed, or unsafe extractor inputs.", + "Follow each sanitized diagnostic and rerun indexing after correcting or removing the affected extension input.", + Meaning: "Bounded, sanitized diagnostics produced while discovering extractor plugins and pattern configurations.", + Source: "The active workspace extractor registry snapshot.", + Dependencies: ["extractors.diagnostic_count", "extractors.diagnostic_limit", "extractors.diagnostics_truncated"], + Interpretation: "Use `diagnostics_truncated` to decide whether the returned list is exhaustive."), + new( + "extractors.registration_precedence", + "Extractor registration precedence", + "the list publishes the deterministic highest-first order used to resolve competing extractor registrations.", + "an absent or unexpected order indicates a contract mismatch between the status reader and active registry.", + "Use a current binary and remove unintended higher-precedence registrations before reindexing.", + Meaning: "The fixed precedence order applied when built-in, user, and workspace extractors overlap.", + Source: "The active immutable extractor registry snapshot.", + Dependencies: ["extractors.snapshot_scope"], + Interpretation: "Earlier entries win when more than one registration handles the same language or pattern."), + new( + "hooks.callback_budget_ms", + "Hook callback budget", + "the positive value is the per-callback time budget enforced for the current index run.", + "a callback exceeding the budget is timed out, its scratch mutation is discarded, and only that hook ID is disabled for the run.", + "Reduce hook work or adjust `CDIDX_HOOK_CALLBACK_BUDGET_MS` within the documented safe range, then rerun indexing.", + Meaning: "The maximum elapsed time allowed for one post-extraction hook callback.", + Source: "The sanitized hook discovery snapshot and effective callback-budget configuration.", + Dependencies: ["hooks.id", "hook_diagnostics"], + Interpretation: "The budget is enforced per hook callback, not as a total budget for the entire index run."), + new( + "hooks.assembly_path", + "Hook assembly path", + "the member identifies the sanitized assembly location associated with a discovered hook.", + "the path is diagnostic context only and must not be treated as authorization to load arbitrary content.", + "Correct the trusted hook directory or assembly permissions, then rerun discovery/indexing.", + Meaning: "A support-safe path label for the assembly that owns the hook manifest entry.", + Source: "The bounded hook discovery worker after trust validation and diagnostic sanitization.", + Dependencies: ["hooks.id", "trust_overrides", "hook_diagnostics"], + Interpretation: "Runtime values are never fetched by `--explain`; this explanation contains contract metadata only."), + new( + "trust_overrides.environment_variable", + "Trust override environment variable", + "the member names the accepted `CDIDX_*` variable that widened a specific executable-extension trust boundary.", + "an absent `trust_overrides` section means no supported trust override was accepted.", + "Unset the variable to return to the default trust boundary, or audit the paired sanitized value/path before continuing.", + Meaning: "Names the environment variable responsible for one accepted trust override.", + Source: "Accepted override snapshots from extractor, hook, and Git executable trust resolvers.", + Dependencies: ["trust_overrides.kind", "trust_overrides.value", "trust_overrides.path"], + Interpretation: "Presence records accepted authority; it is not merely a copy of all `CDIDX_*` environment variables."), + new( + "trust_overrides.path", + "Trust override path", + "the optional member is a sanitized support-safe path associated with the accepted override.", + "absence means the override has no path target or no safe path can be reported.", + "Audit the underlying local path and permissions outside exported status output before trusting executable content.", + Meaning: "Provides redacted path context for a path-bearing accepted trust override.", + Source: "Trust-boundary resolvers after path canonicalization and diagnostic sanitization.", + Dependencies: ["trust_overrides.kind", "trust_overrides.environment_variable"], + Interpretation: "The reported value is intentionally sanitized and may not be an absolute filesystem path."), + ]; + private static readonly StatusFieldExplanation[] StatusExplainFields = StatusReadinessFields.Concat( [ + new( + "reference_extraction_limits", + "Reference extraction safety limits", + "the section publishes the hard lookup, line, names-per-line, and container-candidate limits enforced by this binary.", + "missing or mismatched limits make cap-hit interpretation incomplete for automation.", + "Use a current binary and inspect `reference_extraction_cap_hits` before treating absent graph edges as authoritative.", + Meaning: "Publishes the bounded-work limits that constrain reference extraction.", + Source: "Compiled reference-extractor safety constants serialized with status.", + Dependencies: ["reference_graph_complete", "reference_extraction_cap_hits"], + Interpretation: "These are hard safety ceilings, not observed usage counts."), + new( + "reference_extraction_cap_hits", + "Reference extraction cap hits", + "`state_available=true` with `hit_count=0` means the current generation recorded no reference-extraction cap hit.", + "unavailable state or positive hits make absent callers/callees/deps/impact edges non-authoritative.", + "Inspect `reasons` and bounded `files`, correct pathological/generated input or refresh stale state, then rerun indexing.", + Meaning: "Summarizes whether hard reference-extraction safety caps were reached and which files were affected.", + Source: "Persisted per-file reference-extraction diagnostics summarized for the current index generation.", + Dependencies: ["reference_extraction_limits", "reference_graph_complete", "reference_graph_incomplete_reasons"], + Interpretation: "Check `state_available` before interpreting zero hits, and check `files_truncated` before treating the file list as exhaustive."), + new( + "language_readiness", + "Per-language readiness", + "each language/family entry reports whether its persisted contract is authoritative for the current generation.", + "a `ready=false` member narrows degradation to the named language and contract family.", + "Follow the matching top-level degraded reason/action and rerun the required index or backfill operation.", + Meaning: "Groups readiness signals by indexed language and contract family.", + Source: "Persisted contract stamps evaluated by the status reader.", + Dependencies: ["hotspot_family_ready", "csharp_symbol_name_ready", "csharp_metadata_target_ready"], + Interpretation: "Use this section to localize a top-level readiness failure; it does not replace the top-level compatibility fields."), + new( + "workspace_check", + "Workspace freshness details", + "a matching check accounts for indexed, changed, missing, outside-sparse-cone, unindexed, unverifiable, and scan-error files.", + "non-matching counts and bounded file lists explain why `index_matches_workspace=false`.", + "Run the status-provided repair command or `cdidx index `, then repeat `status --check --json`.", + Meaning: "Detailed result of the optional workspace-to-index freshness comparison.", + Source: "A bounded live workspace scan compared with persisted indexed file metadata.", + Dependencies: ["index_matches_workspace", "query_context", "head_freshness"], + Interpretation: "The section is emitted only when the workspace check runs; omitted lists must be interpreted with their counts/truncation metadata."), + new( + "repair_commands", + "Status repair commands", + "entries provide machine-readable commands scoped to failed checks.", + "absence means no check-specific repair command was generated, not that every optional maintenance action is unnecessary.", + "Review each command, safety note, project/database target, and rebuild flag before execution.", + Meaning: "Bounded remediation commands generated for the failed `status --check` scopes.", + Source: "The status-check failure set and command builders for each readiness contract.", + Dependencies: ["failed_checks", "workspace_check", "readiness_degradations"], + Interpretation: "Commands are guidance and are never executed by `status`."), + new( + "readiness_degradations", + "Readiness degradation inventory", + "an absent list means no tracked readiness field is degraded.", + "each entry records the degraded field, stable root cause, human reason, and remediation.", + "Follow the highest-priority applicable action, then rerun status to confirm every degradation cleared.", + Meaning: "Lists every currently degraded readiness contract rather than only the primary summary.", + Source: "Top-level readiness fields evaluated by the shared degradation registry.", + Dependencies: ["degraded_root_cause", "degraded_reason", "recommended_action", "alternative_action"], + Interpretation: "The first entry drives the primary top-level degradation fields; later entries remain independently actionable."), + new( + "extractors", + "Extractor registry diagnostics", + "the section describes the active immutable extractor snapshot, registration counts/precedence, lifecycle, skipped files, and bounded diagnostics.", + "non-zero skipped/diagnostic counts or truncation identify extension inputs that were not fully accepted.", + "Inspect sanitized diagnostics and pattern configuration entries, correct trust/compatibility problems, then rerun indexing.", + Meaning: "Describes the extractor plugins and pattern configurations active for this workspace snapshot.", + Source: "The workspace-scoped `ExtractorPluginRegistry` snapshot selected at status time.", + Dependencies: ["trust_overrides", "graph_supported_languages"], + Interpretation: "Registration counts describe accepted active registrations; diagnostics describe rejected or degraded candidates."), + new( + "hooks", + "Post-extraction hook manifests", + "entries describe hooks discovered in isolated workers with stable IDs, sanitized assembly/type context, callback budgets, and lifecycle.", + "absence means no hook manifest was accepted; failures and timeouts appear in `hook_diagnostics`.", + "Inspect `hook_diagnostics` and accepted trust overrides, correct the hook assembly/configuration, then rerun indexing.", + Meaning: "Publishes accepted post-extraction hook manifests without executing callbacks during explanation.", + Source: "Deadline-, memory-, and output-bounded hook discovery workers.", + Dependencies: ["hook_diagnostics", "trust_overrides"], + Interpretation: "Manifest presence does not prove every callback succeeded; correlate by assembly-qualified hook `id`."), + new( + "hook_diagnostics", + "Post-extraction hook diagnostics", + "an absent or empty list means discovery/indexing emitted no bounded hook diagnostic.", + "entries use stable categories for discovery, construction, callback, timeout, and trust failures.", + "Follow the sanitized diagnostic, correct or remove the affected hook, and rerun indexing.", + Meaning: "Bounded, sanitized failures and warnings associated with hook discovery or callback execution.", + Source: "Hook discovery workers and the current index run's callback runner.", + Dependencies: ["hooks", "trust_overrides"], + Interpretation: "Use `hook_id` when present to correlate a diagnostic with the corresponding hook manifest."), + new( + "trust_overrides", + "Accepted trust overrides", + "each entry records a supported trust-boundary override that was explicitly accepted after validation.", + "absence means no supported override widened extractor, hook, or Git executable trust for this status snapshot.", + "Audit the named environment variable and sanitized value/path; unset it to return to the default trust boundary.", + Meaning: "Audits accepted environment overrides that authorize workspace plugins, hook directories, or a Git executable.", + Source: "Extractor, hook-directory, and Git executable trust resolvers after validation and sanitization.", + Dependencies: ["extractors", "hooks", "git_executable"], + Interpretation: "Only accepted overrides appear; rejected candidates remain in their owning diagnostic section."), + new( + "db_pragma_settings", + "Database PRAGMA settings", + "the section reports the active journal, durability, checkpoint, timeout, page-count, freelist, page-size, and auto-vacuum settings.", + "unexpected values can explain WAL growth, locking, durability, or maintenance behavior.", + "Compare against the documented SQLite policy and reopen/reindex the intended database after correcting configuration.", + Meaning: "A live snapshot of SQLite PRAGMA values relevant to status trust and maintenance.", + Source: "The active status reader connection and database header/PRAGMA queries.", + Dependencies: ["sqlite_connection_policy", "maintenance_guidance"], + Interpretation: "Values describe this connection/database snapshot and can change after maintenance or reopening."), + new( + "prepared_command_cache", + "Prepared-command cache metrics", + "counts and capacity expose bounded cache occupancy, hits, misses, and evictions for the active reader.", + "high misses/evictions can explain repeated query preparation but do not by themselves degrade correctness.", + "No repair is normally required; use the metrics for performance diagnosis and rerun against a representative workload.", + Meaning: "Runtime metrics for the bounded SQLite prepared-command cache.", + Source: "The active database reader instance at status-call time.", + Dependencies: ["process"], + Interpretation: "Metrics are process-local and reset with a new reader/process."), + new( + "maintenance_guidance", + "Database maintenance guidance", + "`recommended_command=none` means measured WAL/freelist metrics are below their effective warning thresholds; `fts_optimization` reports the shared `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state` decision used by status and optimize.", + "checkpoint/vacuum recommendations are advisory; `state=stale` or `state=unavailable` suppresses FTS optimization when its persisted counter or database-page snapshot is not trustworthy.", + "Review the maintenance command and follow-up with no writer active; rerun status after indexing, and use `cdidx optimize --dry-run --json` before an FTS mutation.", + Meaning: "Classifies WAL and freelist metrics, selects at most one bounded operator maintenance action, and publishes the shared FTS optimization recommendation.", + Source: "Live database/WAL sizes, PRAGMA metrics, effective thresholds, and the persisted incremental-write counter.", + Dependencies: ["db_pragma_settings", "db_size_bytes", "wal_size_bytes", "maintenance_guidance.fts_optimization"], + Interpretation: "Guidance is advisory and never executes a maintenance command or FTS optimization."), + new( + "database_size_attribution", + "Database size attribution", + "`available=true` reconciles bounded physical and logical SQLite page/object size measurements.", + "`available=false` includes a stable unavailable reason; omitted object-byte fields must not be interpreted as zero.", + "Inspect the unavailable reason or largest bounded objects, then use supported maintenance commands if reclamation is appropriate.", + Meaning: "Separates physical DB/WAL/SHM bytes and reconciles logical page ownership with bounded object samples.", + Source: "Read-only SQLite dbstat/page queries and filesystem size observations.", + Dependencies: ["db_size_bytes", "wal_size_bytes", "db_pragma_settings"], + Interpretation: "Use `unexplained_residual_bytes` and availability metadata when reconciling totals."), + new( + "process", + "Status process metrics", + "the section reports heap, GC, and working-set metrics captured for the current process.", + "large values are diagnostic observations and do not alone imply an index correctness failure.", + "Reproduce with the same command/workload and use `last_index_run` memory data when investigating sustained pressure.", + Meaning: "A point-in-time runtime memory/GC snapshot for the process serving status.", + Source: "The .NET runtime and operating-system process APIs at status-call time.", + Dependencies: [], + Interpretation: "Metrics are ephemeral and should be compared across equivalent workloads."), + new( + "last_index_run", + "Last successful index run", + "the section records the most recent successful run's mode, timing, work counts, byte coverage, diagnostics, and optional peak memory.", + "absence indicates a legacy database or missing run metadata; bounded diagnostics can mark best-effort metadata-write problems.", + "Rerun indexing with a current binary to refresh the snapshot; use `--memory-trace` when peak-memory evidence is required.", + Meaning: "A persisted audit snapshot of the last successful full or update index run.", + Source: "Metadata stamped at the end of a successful CLI or MCP index operation.", + Dependencies: ["last_workspace_freshened_at", "index_complete", "reference_extraction_cap_hits"], + Interpretation: "This is historical run metadata, not live progress."), + new( + "last_failed_or_partial_index_run", + "Last failed or partial index run", + "absence means no persisted failed/partial run currently requires operator attention.", + "presence records bounded per-file failures, persisted progress, and recovery guidance while successful data may remain queryable.", + "Fix the structured file/extractor failures and rerun the same index command; do not rebuild unless the guidance says it is required.", + Meaning: "A persisted audit snapshot of the most recent failed or partial index generation.", + Source: "The index runner's bounded failure persistence path.", + Dependencies: ["index_complete", "graph_data_current", "file_issues_data_current"], + Interpretation: "Use `progress_persisted` to distinguish retained successful work from a fully rolled-back attempt."), new( "git_head", "Runtime Git HEAD", @@ -180,12 +491,6 @@ private sealed record StatusFieldExplanation( "the object reports the active/open modes, pooling and immutable-URI choices, command timeout, cancellation requirement, and WAL snapshot-risk diagnostics used by this status read.", "read-only fallback or stale-snapshot risk fields identify when the preferred query-only connection path could not be used safely or may omit hot WAL content.", "Inspect the nested policy and WAL diagnostics; avoid explicit immutable mode for a hot WAL database, or rerun after the writer checkpoints and closes."), - new( - "maintenance_guidance", - "Database maintenance guidance", - "`fts_optimization` reports one shared `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state` decision used by status and optimize.", - "`state=stale` or `state=unavailable` suppresses an optimize recommendation because the persisted write counter or database-page snapshot is not trustworthy.", - "Wait for active indexing to finish and rerun `cdidx status --json`; run `cdidx optimize --dry-run --json` to inspect the same decision before mutation."), new( "unknown_extension_file_count", "Unknown extension inventory", @@ -210,5 +515,7 @@ private sealed record StatusFieldExplanation( "`true` means the indexed workspace path comparison is case-sensitive; `false` means case-insensitive.", "the field is absent on legacy indexes that predate the workspace case-sensitivity stamp.", "Run `cdidx index ` with a current cdidx binary to stamp filesystem case sensitivity."), - ]).ToArray(); + ]) + .Concat(StatusMemberExplainFields) + .ToArray(); } diff --git a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs index 5e2968cdb..aa7822eee 100644 --- a/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs +++ b/tests/CodeIndex.Tests/ProjectionFieldRegistryIssue4836Tests.cs @@ -140,6 +140,54 @@ public void ValidMultipleFieldsAndPathAlias_PreserveProjectionBehavior_Issue4836 } } + [Fact] + public void StatusExplain_StructuredFieldsSupportBoundedProjection_Issue4891() + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "status", + "--explain", + "files", + "--json", + "--fields", + "meaning,source,redaction,known_fields_truncated", + "--max-json-bytes", + "8192" + ], + _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; + var row = Assert.Single(root.GetProperty("results").EnumerateArray()); + Assert.Contains("files", row.GetProperty("meaning").GetString(), StringComparison.Ordinal); + Assert.Contains("StatusResult", row.GetProperty("source").GetString(), StringComparison.Ordinal); + var redaction = row.GetProperty("redaction"); + Assert.False(redaction.GetProperty("runtime_values_included").GetBoolean()); + Assert.False(redaction.GetProperty("paths_included").GetBoolean()); + Assert.True(row.GetProperty("known_fields_truncated").GetBoolean()); + Assert.Equal(4, row.EnumerateObject().Count()); + var metadata = root.GetProperty("metadata"); + Assert.Equal(8192, metadata.GetProperty("max_json_bytes").GetInt32()); + foreach (var forbiddenField in new[] + { + "cdidx_version", + "elapsed_ms", + "db_path", + "indexed_at_head_sha", + "result_stable_at", + }) + { + Assert.False( + metadata.TryGetProperty(forbiddenField, out _), + $"Bounded status explain metadata must omit runtime field '{forbiddenField}'."); + } + Assert.DoesNotContain(Directory.GetCurrentDirectory(), stdout, StringComparison.Ordinal); + } + [Fact] public void RegistryAliasesAndNestedCollections_AreMachineDiscoverable_Issue4836() { @@ -186,6 +234,16 @@ public void EveryDiscoveredProjectionField_ValidatesFromTheSameRegistry_Issue483 [InlineData("status", "index_matches_workspace")] [InlineData("status", "effective_config")] [InlineData("status", "update_check")] + [InlineData("status", "scope")] + [InlineData("status", "meaning")] + [InlineData("status", "source")] + [InlineData("status", "dependencies")] + [InlineData("status", "dependencies_truncated")] + [InlineData("status", "interpretation")] + [InlineData("status", "repair_guidance")] + [InlineData("status", "redaction")] + [InlineData("status", "known_field_limit")] + [InlineData("status", "known_fields_truncated")] [InlineData("references", "body_content")] [InlineData("callers", "aggregate_truncated")] [InlineData("callers", "first_column")] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index d7e13dd20..278512f28 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -1917,6 +1917,148 @@ public void RunStatus_ExplainJson_PrintsMachineReadableDescription() Assert.Contains("immutable-URI", policyDocument.RootElement.GetProperty("ready").GetString(), StringComparison.Ordinal); } + [Theory] + [InlineData("trust_overrides", "Accepted trust overrides", "environment overrides", "extractors")] + [InlineData("extractors", "Extractor registry diagnostics", "extractor plugins", "trust_overrides")] + [InlineData("hooks", "Post-extraction hook manifests", "hook manifests", "hook_diagnostics")] + [InlineData("maintenance_guidance", "Database maintenance guidance", "WAL and freelist", "db_pragma_settings")] + [InlineData("reference_extraction_cap_hits", "Reference extraction cap hits", "safety caps", "reference_extraction_limits")] + public void RunStatus_ExplainJson_PrintsStructuredMajorSectionMetadata_Issue4891( + string field, + string label, + string meaningText, + string dependency) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", field, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + Assert.Equal(field, json.GetProperty("field").GetString()); + Assert.Equal(label, json.GetProperty("label").GetString()); + Assert.Equal("top_level", json.GetProperty("scope").GetString()); + Assert.Contains(meaningText, json.GetProperty("meaning").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("source").GetString())); + Assert.Contains( + dependency, + json.GetProperty("dependencies").EnumerateArray().Select(item => item.GetString())); + Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("interpretation").GetString())); + Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("repair_guidance").GetString())); + Assert.False(json.GetProperty("redaction").GetProperty("runtime_values_included").GetBoolean()); + Assert.False(json.GetProperty("redaction").GetProperty("paths_included").GetBoolean()); + } + + [Theory] + [InlineData("maintenance_guidance.recommended_command", "Recommended maintenance command", "single maintenance action", "maintenance_guidance.wal_state")] + [InlineData("maintenance_guidance.fts_optimization", "FTS optimization guidance", "optimizing FTS indexes", "maintenance_guidance.fts_optimization.state")] + [InlineData("reference_extraction_cap_hits.files", "Reference cap-hit file sample", "bounded", "reference_extraction_cap_hits.file_limit")] + [InlineData("extractors.diagnostics", "Extractor diagnostics", "sanitized diagnostics", "extractors.diagnostic_count")] + [InlineData("hooks.callback_budget_ms", "Hook callback budget", "maximum elapsed time", "hook_diagnostics")] + [InlineData("trust_overrides.path", "Trust override path", "redacted path context", "trust_overrides.kind")] + public void RunStatus_ExplainJson_PrintsNestedMemberMetadataWithoutRuntimeValues_Issue4891( + string field, + string label, + string meaningText, + string dependency) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", field, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + Assert.Equal(field, json.GetProperty("field").GetString()); + Assert.Equal(label, json.GetProperty("label").GetString()); + Assert.Equal("member", json.GetProperty("scope").GetString()); + Assert.Contains(meaningText, json.GetProperty("meaning").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains( + dependency, + json.GetProperty("dependencies").EnumerateArray().Select(item => item.GetString())); + Assert.False(json.GetProperty("redaction").GetProperty("runtime_values_included").GetBoolean()); + Assert.False(json.GetProperty("redaction").GetProperty("paths_included").GetBoolean()); + Assert.DoesNotContain(Directory.GetCurrentDirectory(), stdout, StringComparison.Ordinal); + } + + [Fact] + public void RunStatus_Explain_CoversEverySerializedTopLevelFieldFromSerializerRegistry_Issue4891() + { + var serializedFields = QueryCommandRunner.GetStatusSerializableFieldNames(_jsonOptions); + + Assert.NotEmpty(serializedFields); + Assert.DoesNotContain("indexed_follow_symlinks_policy", serializedFields); + foreach (var field in serializedFields) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", field, "--json"], + _jsonOptions)); + + Assert.True( + exitCode == CommandExitCodes.Success, + $"Expected serialized status field `{field}` to be explainable, but got exit {exitCode}: {stderr}{stdout}"); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + Assert.Equal(field, document.RootElement.GetProperty("field").GetString()); + } + + var (_, knownFieldsStdout, _) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", "files", "--json"], + _jsonOptions)); + using var knownFieldsDocument = ParseJsonOutput(knownFieldsStdout); + var knownFields = knownFieldsDocument.RootElement + .GetProperty("known_fields") + .EnumerateArray() + .Select(item => item.GetString()) + .ToHashSet(StringComparer.Ordinal); + Assert.All(serializedFields, field => Assert.Contains(field, knownFields)); + Assert.True(knownFieldsDocument.RootElement.GetProperty("known_fields_truncated").GetBoolean()); + Assert.Equal( + knownFieldsDocument.RootElement.GetProperty("known_field_limit").GetInt32(), + knownFields.Count); + } + + [Fact] + public void RunStatus_ExplainUnknownNestedJsonReturnsBoundedValidCandidatesAndRedactsInput_Issue4891() + { + var (nestedExitCode, nestedStdout, nestedStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", "maintenance_guidance.nope", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, nestedExitCode); + Assert.Equal(string.Empty, nestedStderr); + using var nestedDocument = ParseJsonOutput(nestedStdout); + Assert.Contains( + "maintenance_guidance.recommended_command", + nestedDocument.RootElement.GetProperty("hint").GetString(), + StringComparison.Ordinal); + + var (typoExitCode, typoStdout, typoStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", "db_pragma_settings.journal_mdoe", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, typoExitCode); + Assert.Equal(string.Empty, typoStderr); + using var typoDocument = ParseJsonOutput(typoStdout); + Assert.Contains( + "db_pragma_settings.journal_mode", + typoDocument.RootElement.GetProperty("hint").GetString(), + StringComparison.Ordinal); + + var sensitiveInput = "/Users/example/.ssh/id_rsa-" + new string('x', 400); + var (redactedExitCode, redactedStdout, redactedStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", sensitiveInput, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, redactedExitCode); + Assert.Equal(string.Empty, redactedStderr); + Assert.DoesNotContain("/Users/example", redactedStdout, StringComparison.Ordinal); + Assert.True(redactedStdout.Length < 5000, $"Expected bounded error output, got {redactedStdout.Length} characters."); + } + [Fact] public void RunStatus_ExplainJson_PrintsIndexMatchesWorkspaceDescription_Issue4317() {