From 82a91088402cdebcf56a4e5da252807af58d8bd6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 23:24:29 +0900 Subject: [PATCH] Add doctor integration readiness summary (#5102) --- DEVELOPER_GUIDE.md | 4 +- README.md | 2 + TESTING_GUIDE.md | 1 + USER_GUIDE.md | 4 + changelog.d/unreleased/5102.added.md | 30 + src/CodeIndex/Cli/CliFlagSchema.cs | 3 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/GitHelper.IndexFile.cs | 490 +++++++++++++ src/CodeIndex/Cli/HookCommandRunner.cs | 90 +++ src/CodeIndex/Cli/IndexFreshnessChecker.cs | 65 +- .../Cli/IndexWatchRunner.Watchers.cs | 2 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 102 +++ .../Cli/ProgramRunner.Doctor.Integrations.cs | 663 ++++++++++++++++++ src/CodeIndex/Cli/ProgramRunner.Doctor.cs | 51 +- .../Extensibility/ExtractorPluginRegistry.cs | 29 + .../ExtractorPluginRegistryTests.cs | 61 ++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 461 ++++++++++++ 17 files changed, 2042 insertions(+), 18 deletions(-) create mode 100644 changelog.d/unreleased/5102.added.md create mode 100644 src/CodeIndex/Cli/GitHelper.IndexFile.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Doctor.Integrations.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 72d6f7766..a95ab3c92 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -35,7 +35,7 @@ Development contracts: | CLI help | `cdidx --help` stays brief, `cdidx --help-all` prints the full command/flag/example reference, `cdidx --help-flags` prints only shared flag tables, and `cdidx --help` prints one command's usage line. Keep new commands visible in the brief summary only when they are a primary user workflow; every command must remain present in the full help and command-specific usage table. | | `index --dry-run` mutation estimates | Dry-run remains strictly non-mutating: it does not create or lock the database and does not alter sources or index artifacts. It uses read-only stat/checksum data together with the production cap-issue, symbol-filter, extractor-version, graph-contract, forced extractor/config refresh, hotspot-marker trust, and C# workspace reuse gates to project unchanged skips; projects updates, content-policy skips, deletes, purges, and symbol/reference cap hits; and performs the normal chunk/symbol/reference/content-diagnostic extraction for at most 100 planned update files. `estimated_table_mutations` counts projected delete-and-insert row operations, not final table cardinality. Its nullable values must stay synchronized with `estimated_table_mutation_details`: `source` identifies filesystem planning or parse-only plus index-snapshot input, `confidence` is `exact`, `estimate`, or `unknown`, and stable `unknown_reasons` cover candidate truncation, parse-estimate truncation/failure, C# workspace expansion, unreadable index snapshots, and partial-index table absence. A numeric zero is reserved for measured no work and remains qualified by its confidence. Parse-only estimates intentionally omit post-extraction hook mutations and cross-file C# workspace augmentation, so child-table values remain estimates; when a changed C# source proves that production must expand the refresh across otherwise reusable C# files, those child-table metrics become explicitly unknown instead of retaining incomplete parse totals. | | `cdidx validate` | This is the user-facing integrity scan for indexed content issues such as replacement characters, BOMs, NUL bytes, mixed line endings, UTF-16 BOMs, and likely non-UTF8 content. Keep its CLI usage, README entry, and help summary in sync when adding validation issue kinds or filters. | -| `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid only with `--json --env-inventory=full`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | +| `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid with `--json --env-inventory=full` or `--integrations --json`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | | Exception diagnostics | User-facing CLI, JSON, MCP, file-issue, and local diagnostic output must not echo raw `ex.Message` directly. Route exception prose through `CommandErrorWriter.FormatSanitizedExceptionMessage`, `DiagnosticSanitizer.ForMessage`, or an existing bounded `DiagnosticRedactor` helper, and use stable error codes/categories when the message is not needed for recovery. Intentional broad catches should match the `risky-code/broad-exception-catch` taxonomy and normalize to bounded diagnostics, private best-effort suppression, or a documented fallback. | | Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. Completion candidates come from `CliFlagSchema`: `ValueKind` / `CommandValueKinds` select contextual path, project, repository, language, and symbol-kind behavior; `ValueDomain` / `CommandValueDomains` define exhaustive finite choices; and `SupplementalCompletionValues` preserves real reserved literals for mixed inputs such as path-or-`github`. Display placeholders such as `` are metavariables and must never be parsed into candidates. When command or flag schema changes, update completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | | Target frameworks | The production CLI and NuGet tool packaging target `net8.0`. The test project multi-targets `net8.0;net9.0`, and CI runs the test suite on both frameworks across Linux, Windows, and macOS. Use a .NET SDK that can restore and run both target frameworks when validating the full CI-equivalent test matrix. | @@ -3969,7 +3969,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | CLI help | `cdidx --help` は短い概要、`cdidx --help-all` は全コマンド・flag・例の一覧、`cdidx --help-flags` は共有 flag table のみ、`cdidx --help` は 1 コマンドの usage line を出します。新しいコマンドは主要な user workflow である場合だけ簡易概要に載せ、full help とコマンド固有の usage table には必ず載せてください。 | | `index --dry-run` の mutation 推定 | dry-run は厳密に非変更のままにし、database の作成・lock や source / index artifact の変更を行いません。read-only な stat / checksum と production の cap issue、symbol filter、extractor version、graph contract、extractor / config の強制 refresh、hotspot marker の trust、C# workspace の再利用条件を組み合わせて未変更 skip を判定し、update、内容に対する policy skip、delete、purge、symbol / reference 上限到達を予測し、update 予定 file のうち最大100件に通常の chunk / symbol / reference / content diagnostic 抽出を適用します。`estimated_table_mutations` は最終 table row 数ではなく、delete と insert の予測 row operation 数です。nullable な値は `estimated_table_mutation_details` と常に同期させます。`source` は filesystem plan または parse-only と index snapshot の入力元、`confidence` は `exact` / `estimate` / `unknown`、安定した `unknown_reasons` は candidate の切り詰め、parse 推定の切り詰め・失敗、C# workspace の展開、index snapshot の読み取り不能、partial index の table 不足を表します。数値のゼロは、処理がないと計測できた場合だけに使い、その信頼度も併記します。parse-only 推定では extraction 後の hook mutation と C# の cross-file workspace augmentation を意図的に省くため、child table の値は estimate のままです。変更された C# source によって production が本来再利用可能な C# file にも refresh を展開すると判明した場合は、不完全な parse 合計を残さず、それらの child-table metric を明示的な unknown にします。 | | `cdidx validate` | replacement character、BOM、NUL byte、混在改行、UTF-16 BOM、非 UTF-8 らしい内容など、indexed content の問題を user-facing に検査する integrity scan です。validation issue の種別や filter を追加する場合は、CLI usage、README entry、help summary を同期してください。 | -| `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` とだけ組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | +| `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` または `--integrations --json` と組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | | 例外診断 | user-facing な CLI / JSON / MCP / file issue / local diagnostic output では raw `ex.Message` を直接 echo しないでください。例外の prose は `CommandErrorWriter.FormatSanitizedExceptionMessage`、`DiagnosticSanitizer.ForMessage`、または既存の bounded な `DiagnosticRedactor` helper を通し、回復に message が不要な場合は安定した error code/category を使ってください。意図的に残す broad catch は `risky-code/broad-exception-catch` taxonomy に沿い、bounded diagnostic、private な best-effort suppression、または documented fallback に正規化してください。 | | shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。completion candidate は `CliFlagSchema` を基準にし、`ValueKind` / `CommandValueKinds` が path、project、repository、language、symbol kind の文脈別動作を選び、`ValueDomain` / `CommandValueDomains` は網羅的な有限候補を定義し、`SupplementalCompletionValues` は path または `github` のような混合入力で実在する予約 literal を維持します。`` のような表示用 placeholder は metavariable であり、候補へ分解してはいけません。command や flag の schema を変えた場合は completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | | target framework | 製品版 CLI と NuGet tool packaging は `net8.0` を対象にしています。test project は `net8.0;net9.0` の multi-target で、CI は Linux、Windows、macOS の各 lane で両方の framework に対して test suite を実行します。CI 相当の full matrix を検証する場合は、両方の target framework を restore / 実行できる .NET SDK を使ってください。 | diff --git a/README.md b/README.md index c19a38131..104eeceec 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ cdidx inspect QueryCommandRunner --outline-only cdidx map --compact --max-json-bytes 65536 cdidx audit risky-code --format sarif --limit 20 cdidx doctor --json +cdidx doctor --integrations --json cdidx validate ``` @@ -284,6 +285,7 @@ cdidx inspect QueryCommandRunner --outline-only cdidx map --compact --max-json-bytes 65536 cdidx audit risky-code --format sarif --limit 20 cdidx doctor --json +cdidx doctor --integrations --json cdidx validate ``` diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 90e89b1e7..ef5adc21b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -870,6 +870,7 @@ Use the inventory below before adding or moving a test class: - Metrics flag/language parsing and deterministic retry-delay calculation use a standalone argument class; active sink sessions, rotation, queues, diagnostics, and file writes remain in the pool-sensitive metrics class. - Database maintenance parsing for integrity, schema projection, prune/checkpoint/restore, size controls, and invalid combinations uses a standalone parser class; direct SQLite execution and pool-release helpers remain pool-sensitive. - Program project-path classification across common, POSIX literal-backslash, and Windows path forms uses a standalone cross-platform class; environment, transport, process, and database behaviors remain pool-sensitive. +- Doctor integration-readiness database, workspace, hook, redaction, and MCP tests stay in the SQLite-pool-sensitive `ProgramRunnerTests` fixture and resolve temporary databases through `CDIDX_DATA_DIR`. The plugin-worker non-start assertion belongs in `ExtractorPluginRegistryTests`; restore its registry state, worker hook, current directory, and environment in `finally` so process-global state cannot leak. - MCP audit-log integration stays in the console-sensitive collection: request-correlated server diagnostics write directly to stderr and can otherwise contaminate concurrently captured command output. - Audit SARIF UTF-8 byte-budget, whole-result truncation, replay, help, and ad-hoc rejection regressions use one standalone class so their isolated recipe databases can run independently of pool-reset coverage. - Files/symbols top-level and positional path-filter regressions use a standalone class with shared JSON parsing so their isolated databases can run in parallel with the pool-sensitive query class. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 46ee90e82..64fe147f0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2672,6 +2672,8 @@ Precedence is **CLI flag > environment variable > config file > built-in default Secrets are intentionally **not** loadable from the file: `CDIDX_GITHUB_TOKEN`, `CDIDX_MCP_AUTH_TOKEN`, and `CDIDX_MCP_HTTP_TOKEN` are env-only so tokens never get checked into version control. +Run `cdidx doctor --integrations` for one read-only integration-readiness snapshot covering the active project/workspace and resolved database freshness, the current Git hook target, MCP transports/auth/audit, LSP launch resolution, the preferred watch backend, and discovered versus loaded plugin/pattern configuration. Every section uses the stable states `ready`, `warning`, `error`, `not_configured`, or `not_applicable` and includes copyable remediation when action is useful. `--json` emits schema version `1` from the same typed model; paths are redacted by default, secret values are never emitted, and MCP auth reports only presence and source. Add `--show-paths` only for local diagnosis. Workspace member samples and extension diagnostics are capped at 20 with omission metadata, configuration discovery is capped at 128 candidates per directory, and `--max-json-bytes ` rejects an oversized JSON document. The snapshot does not start an MCP listener, LSP session, watch loop, plugin worker, network request, or Git subprocess, and does not install or modify anything. Optional `not_configured` / `not_applicable` sections do not lower the overall state; `--check` exits `5` when an actionable `warning` or `error` remains. + Run `cdidx doctor --env-inventory` to audit a compact environment-variable summary grouped by `domain`, sensitivity, and category. Use `cdidx doctor --env-inventory=full` for the per-variable audit view with source locations and `invalid_value_behavior`, or `cdidx doctor --json --env-inventory=full` when a machine-readable full inventory is needed locally. The full inventory accepts case-insensitive exact `--env-domain`, `--env-category`, and `--env-sensitivity` filters that compose with AND. Add `--max-json-bytes ` to the JSON full-inventory form to reject a successful document whose UTF-8 bytes, including its newline, exceed the requested budget; narrow the inventory with filters or increase the budget when that structured error is returned. `doctor --json` and `config show --json` redact local paths by default and expose the compact `environment_inventory_summary`; add `--show-paths` only for local debugging when raw path diagnostics are required. Secret-bearing variables such as `CDIDX_GITHUB_TOKEN`, `CDIDX_MCP_AUTH_TOKEN`, and `CDIDX_MCP_HTTP_TOKEN` are marked `auth_secret` and are redacted from doctor/config diagnostics; trust-boundary variables such as MCP tool filters, workspace plugin trust, hook directories, and GitHub proxy credential opt-ins document whether invalid values fail closed, warn, or leave the feature disabled. Supported schema (top-level keys are snake_case; nested indexing kind keys keep the CLI issue spelling; every key is optional): @@ -6191,6 +6193,8 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が シークレットは意図的に**ファイルから読み込めません**。`CDIDX_GITHUB_TOKEN` / `CDIDX_MCP_AUTH_TOKEN` / `CDIDX_MCP_HTTP_TOKEN` は環境変数専用としており、トークンがバージョン管理に混入するのを防ぎます。 +`cdidx doctor --integrations` を実行すると、active project / workspace、解決済み database の freshness、現在の Git hook target、MCP transport / auth / audit、LSP 起動解決、優先 watch backend、plugin / pattern 設定の discovered / loaded 状態を、1 回の read-only snapshot で確認できます。各 section は安定した `ready` / `warning` / `error` / `not_configured` / `not_applicable` を使い、対応が有用な場合は copy 可能な remediation を含みます。`--json` は同じ typed model から schema version `1` を出力します。path は既定で redact され、secret 値は決して出力せず、MCP auth は存在と source だけを報告します。raw path が必要な local 診断でのみ `--show-paths` を追加してください。workspace member sample と extension 診断は省略 metadata 付きで 20 件まで、設定 discovery は directory ごとに 128 candidate までに制限され、`--max-json-bytes ` で上限超過 JSON を拒否できます。snapshot は MCP listener、LSP session、watch loop、plugin worker、network request、Git subprocess を起動せず、install や設定変更も行いません。optional な `not_configured` / `not_applicable` section は overall state を下げません。actionable な `warning` または `error` が残るとき、`--check` は終了コード `5` を返します。 + `cdidx doctor --env-inventory` で、`domain`、sensitivity、category ごとに集約した compact な環境変数 summary を監査できます。変数ごとの source location や `invalid_value_behavior` まで確認する場合は `cdidx doctor --env-inventory=full` を使い、ローカルで machine-readable な full inventory が必要な場合は `cdidx doctor --json --env-inventory=full` を使います。full inventory では、大文字小文字を区別しない完全一致の `--env-domain`、`--env-category`、`--env-sensitivity` filter を AND で組み合わせられます。JSON の full-inventory 形式に `--max-json-bytes ` を追加すると、改行を含む UTF-8 byte 数が指定した budget を超える成功文書を拒否します。structured error が返った場合は filter で inventory を絞るか、budget を増やしてください。`doctor --json` と `config show --json` は既定でローカルパスを redact し、compact な `environment_inventory_summary` を出力します。raw path の診断が必要なローカル debugging 時だけ `--show-paths` を追加してください。`CDIDX_GITHUB_TOKEN` / `CDIDX_MCP_AUTH_TOKEN` / `CDIDX_MCP_HTTP_TOKEN` のような secret 変数は `auth_secret` として扱われ、doctor / config 診断では redact されます。MCP tool filter、workspace plugin trust、hook directory、GitHub proxy credential opt-in のような trust-boundary 変数は、不正値が fail closed になるのか、警告されるのか、機能を無効のままにするのかを inventory に明示します。 対応スキーマ(top-level key は snake_case、ネストした indexing の kind key は CLI issue の表記を維持、すべて任意): diff --git a/changelog.d/unreleased/5102.added.md b/changelog.d/unreleased/5102.added.md new file mode 100644 index 000000000..28a947d8e --- /dev/null +++ b/changelog.d/unreleased/5102.added.md @@ -0,0 +1,30 @@ +--- +category: added +issues: + - 5102 +affected: + - src/CodeIndex/Cli/ProgramRunner.Doctor.cs + - src/CodeIndex/Cli/ProgramRunner.Doctor.Integrations.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/HookCommandRunner.cs + - src/CodeIndex/Cli/GitHelper.IndexFile.cs + - src/CodeIndex/Cli/IndexFreshnessChecker.cs + - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - README.md + - DEVELOPER_GUIDE.md + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Added a read-only integration readiness summary to `cdidx doctor` (#5102)** — `doctor --integrations` now reports bounded, path-redacted project/workspace and database freshness, Git hook scope, MCP auth/audit source, LSP launch resolution, watch backend, and plugin/pattern readiness from one typed model; `--json`, `--show-paths`, `--max-json-bytes`, and an optional failing `--check` mode are supported without starting integrations or changing configuration. + +## 日本語 + +- **`cdidx doctor` に read-only な integration readiness summary を追加しました (#5102)** — `doctor --integrations` は project/workspace と database freshness、Git hook scope、MCP auth/audit source、LSP 起動解決、watch backend、plugin/pattern readiness を1つの typed model から上限付き・path redact 済みで報告します。integration を起動したり設定を変更したりせず、`--json`、`--show-paths`、`--max-json-bytes`、任意の failing `--check` mode を利用できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 08ee11ecb..9ebb4b078 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -575,6 +575,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--sample", ValuePlaceholder = "", Description = "Search/Audit row output: deterministically sample returned rows down to n results", PrimaryCommands = Set("search", "audit") }, new() { Name = "--per-file-limit", ValuePlaceholder = "", Description = "Search grouped output: representative matches per file", PrimaryCommands = Set("search") }, new() { Name = "--total-limit", ValuePlaceholder = "", Description = "Search/Audit recipes: cap emitted rows across all child queries", PrimaryCommands = Set("search", "audit") }, + new() { Name = "--integrations", Description = "Doctor: summarize project, hook, MCP, LSP, watch, and extension readiness", PrimaryCommands = Set("doctor") }, new() { Name = "--env-inventory", Description = "Doctor: include a compact environment-variable summary; use --env-inventory=full for the full inventory", PrimaryCommands = Set("doctor") }, new() { Name = "--env-domain", ValuePlaceholder = "", Description = "Doctor full environment inventory: filter by exact domain", PrimaryCommands = Set("doctor") }, new() { Name = "--env-category", ValuePlaceholder = "", Description = "Doctor full environment inventory: filter by exact category", PrimaryCommands = Set("doctor") }, @@ -622,7 +623,7 @@ private static IReadOnlyList BuildAll() }, }, new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; JSON keeps capped paths plus full definition details", PrimaryCommands = Set("hotspots") }, - new() { Name = "--check", Description = "Verify status freshness/readiness", PrimaryCommands = Set("status") }, + new() { Name = "--check", Description = "Verify status freshness/readiness; doctor integrations fails on warning or error", PrimaryCommands = Set("status", "doctor") }, new() { Name = "--config", Description = "Print effective configuration with source attribution", PrimaryCommands = Set("status") }, new() { Name = "--stale-after", ValuePlaceholder = "", Description = "Status: freshness age threshold (e.g. 30m, 2h, 7d)", PrimaryCommands = Set("status") }, new() { Name = "--explain", ValuePlaceholder = "", Description = "Explain one visible status field", PrimaryCommands = Set("status") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index c78d46f37..597773390 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -111,7 +111,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("config", "cdidx config show [--json] [--show-paths]"), ("config-show", "cdidx config show [--json] [--show-paths]"), ("validate-config", "cdidx validate-config [--json]"), - ("doctor", "cdidx doctor [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"), + ("doctor", "cdidx doctor [--integrations [--check]] [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"), ("db", "cdidx db integrity|--integrity-check [--db ] [--show-paths] [--json]"), ("db", $"cdidx db schema [--type ] [--name ] [--limit ] [--max-sql-chars ] [--summary-only] [--include-internal|--exclude-internal] [--db ] [--json]"), ("db", "cdidx db prune --dry-run|--apply [--db ] [--json]"), diff --git a/src/CodeIndex/Cli/GitHelper.IndexFile.cs b/src/CodeIndex/Cli/GitHelper.IndexFile.cs new file mode 100644 index 000000000..12278238c --- /dev/null +++ b/src/CodeIndex/Cli/GitHelper.IndexFile.cs @@ -0,0 +1,490 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class GitHelper +{ + private const int MaxDoctorGitIndexBytes = 128 * 1024 * 1024; + private const int MaxDoctorGitIndexEntries = 2_000_000; + private const int MaxDoctorGitIndexPathBytes = 32 * 1024; + private const int MaxDoctorPackedRefsBytes = 16 * 1024 * 1024; + private static readonly UTF8Encoding StrictGitIndexUtf8 = new(false, true); + + internal static string? TryFindWorktreeRootWithoutProcess(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var current = new DirectoryInfo(fullPath); + while (current != null) + { + var dotGit = Path.Combine(current.FullName, ".git"); + var probe = FileSystemBoundary.TryGetAttributes(LongPath.EnsureWindowsPrefix(dotGit), out var attributes); + if (probe == FileSystemBoundaryProbeStatus.Found + && (TryValidateGitMetadataEntry( + dotGit, + expectDirectory: (attributes & FileAttributes.Directory) != 0, + out _))) + { + return current.FullName; + } + if (probe != FileSystemBoundaryProbeStatus.Missing) + return null; + + current = current.Parent; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + + return null; + } + + internal static HashSet? TryReadSkipWorktreePathsWithoutProcess(string projectRoot) + { + try + { + var worktreeRoot = TryFindWorktreeRootWithoutProcess(projectRoot); + if (worktreeRoot == null + || !TryResolveWorktreeGitDirectoryWithoutProcess(worktreeRoot, out var gitDirectory) + || !TryResolveGitMetadataChildPath( + gitDirectory, + "index", + expectDirectory: false, + allowMissing: false, + out var indexPath)) + { + return null; + } + + var bytes = DataDirectorySecurity.ReadBytesWithinLimit( + LongPath.EnsureWindowsPrefix(indexPath), + MaxDoctorGitIndexBytes, + FileShare.ReadWrite); + if (bytes == null || bytes.Length < 32) + return null; + + var hashLength = ResolveGitIndexHashLength(bytes); + if (hashLength == 0 + || !TryParseSkipWorktreePaths(bytes, hashLength, out var repositoryPaths)) + { + return null; + } + + return RebaseSkipWorktreePaths(repositoryPaths, worktreeRoot, projectRoot); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or DecoderFallbackException + or CryptographicException + or OverflowException) + { + return null; + } + } + + internal static string? TryReadHeadCommitWithoutProcess(string projectRoot) + { + try + { + var worktreeRoot = TryFindWorktreeRootWithoutProcess(projectRoot); + if (worktreeRoot == null + || !TryResolveWorktreeGitDirectoryWithoutProcess(worktreeRoot, out var gitDirectory) + || !TryResolveGitMetadataChildPath( + gitDirectory, + "HEAD", + expectDirectory: false, + allowMissing: false, + out var headPath)) + { + return null; + } + + var head = DataDirectorySecurity.ReadTextWithinLimit( + LongPath.EnsureWindowsPrefix(headPath), + MaxGitMetadataFileBytes)?.Trim(); + if (IsFullGitObjectId(head)) + return head; + if (head == null || !head.StartsWith("ref: ", StringComparison.Ordinal)) + return null; + + var referenceName = head["ref: ".Length..].Trim(); + if (!IsSafeGitReferenceName(referenceName)) + return null; + + var commonDirectory = ResolveGitCommonDir(worktreeRoot); + if (TryReadLooseGitReference(gitDirectory, referenceName, out var objectId) + || (commonDirectory != null + && !PathCasing.PathsEqual(commonDirectory, gitDirectory) + && TryReadLooseGitReference(commonDirectory, referenceName, out objectId))) + { + return objectId; + } + + return commonDirectory == null + ? null + : TryReadPackedGitReference(commonDirectory, referenceName); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException) + { + return null; + } + } + + private static bool TryResolveWorktreeGitDirectoryWithoutProcess( + string worktreeRoot, + out string gitDirectory) + { + var dotGit = Path.Combine(worktreeRoot, ".git"); + if (TryValidateGitMetadataEntry(dotGit, expectDirectory: true, out gitDirectory)) + return true; + if (!TryValidateGitMetadataEntry(dotGit, expectDirectory: false, out var gitFile)) + { + gitDirectory = string.Empty; + return false; + } + + var content = DataDirectorySecurity.ReadTextWithinLimit( + LongPath.EnsureWindowsPrefix(gitFile), + MaxGitMetadataFileBytes); + if (content == null) + { + gitDirectory = string.Empty; + return false; + } + + content = content.Trim(); + if (!content.StartsWith("gitdir:", StringComparison.Ordinal) + || !TryResolveGitMetadataPath( + worktreeRoot, + content["gitdir:".Length..].Trim(), + out gitDirectory) + || !TryValidateGitMetadataEntry(gitDirectory, expectDirectory: true, out gitDirectory)) + { + gitDirectory = string.Empty; + return false; + } + + return true; + } + + private static int ResolveGitIndexHashLength(byte[] bytes) + { + if (bytes.Length > SHA256.HashSizeInBytes + && SHA256.HashData(bytes.AsSpan(0, bytes.Length - SHA256.HashSizeInBytes)) + .AsSpan() + .SequenceEqual(bytes.AsSpan(bytes.Length - SHA256.HashSizeInBytes))) + { + return SHA256.HashSizeInBytes; + } + + if (bytes.Length > SHA1.HashSizeInBytes + && SHA1.HashData(bytes.AsSpan(0, bytes.Length - SHA1.HashSizeInBytes)) + .AsSpan() + .SequenceEqual(bytes.AsSpan(bytes.Length - SHA1.HashSizeInBytes))) + { + return SHA1.HashSizeInBytes; + } + + return 0; + } + + private static bool TryParseSkipWorktreePaths( + byte[] bytes, + int hashLength, + out HashSet paths) + { + paths = new HashSet(StringComparer.Ordinal); + var contentLength = bytes.Length - hashLength; + if (contentLength < 12 + || !bytes.AsSpan(0, 4).SequenceEqual("DIRC"u8)) + { + return false; + } + + var version = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(4, 4)); + var entryCount = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(8, 4)); + if (version is < 2 or > 4 || entryCount > MaxDoctorGitIndexEntries) + return false; + + var objectIdBytes = hashLength; + var offset = 12; + byte[] previousPathBytes = []; + for (var entryIndex = 0U; entryIndex < entryCount; entryIndex++) + { + var entryStart = offset; + var fixedBytes = checked(40 + objectIdBytes + 2); + if (offset > contentLength - fixedBytes) + return false; + var mode = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(entryStart + 24, 4)); + offset += 40 + objectIdBytes; + var flags = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(offset, 2)); + offset += 2; + + ushort extendedFlags = 0; + if ((flags & 0x4000) != 0) + { + if (offset > contentLength - 2) + return false; + extendedFlags = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(offset, 2)); + offset += 2; + } + + byte[] pathBytes; + if (version == 4) + { + if (!TryReadGitIndexVarInt(bytes, contentLength, ref offset, out var removeCount) + || removeCount > previousPathBytes.Length + || !TryReadGitIndexPath(bytes, contentLength, ref offset, expectedLength: null, out var suffixBytes) + || previousPathBytes.Length - removeCount + suffixBytes.Length > MaxDoctorGitIndexPathBytes) + { + return false; + } + + var prefixLength = previousPathBytes.Length - removeCount; + pathBytes = new byte[prefixLength + suffixBytes.Length]; + previousPathBytes.AsSpan(0, prefixLength).CopyTo(pathBytes); + suffixBytes.CopyTo(pathBytes, prefixLength); + } + else + { + var encodedLength = flags & 0x0fff; + if (!TryReadGitIndexPath( + bytes, + contentLength, + ref offset, + encodedLength == 0x0fff ? null : encodedLength, + out pathBytes)) + { + return false; + } + + var padding = (8 - ((offset - entryStart) & 7)) & 7; + if (offset > contentLength - padding) + return false; + offset += padding; + } + + var path = StrictGitIndexUtf8.GetString(pathBytes); + if (path.Any(char.IsControl)) + return false; + previousPathBytes = pathBytes; + if ((extendedFlags & 0x4000) != 0) + { + var normalizedPath = FileIndexer.NormalizePathSeparators(path); + if ((mode & 0xf000) == 0x4000 && !normalizedPath.EndsWith("/", StringComparison.Ordinal)) + normalizedPath += "/"; + paths.Add(normalizedPath); + } + } + + return ValidateGitIndexExtensions(bytes, contentLength, offset); + } + + private static bool ValidateGitIndexExtensions(byte[] bytes, int contentLength, int offset) + { + while (offset < contentLength) + { + if (offset > contentLength - 8) + return false; + var signature = bytes.AsSpan(offset, 4); + var extensionLength = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(offset + 4, 4)); + offset += 8; + if (extensionLength > int.MaxValue || offset > contentLength - (int)extensionLength) + return false; + + // A split index stores its base entries in sharedindex.. Returning the delta's + // skip flags as complete would turn sparse paths into false deletions. The caller + // treats this unsupported layout as unavailable evidence. + if (signature.SequenceEqual("link"u8)) + return false; + offset += (int)extensionLength; + } + + return offset == contentLength; + } + + private static HashSet? RebaseSkipWorktreePaths( + HashSet repositoryPaths, + string worktreeRoot, + string projectRoot) + { + var fullProjectRoot = Path.GetFullPath(projectRoot); + if (!PathCasing.IsPathEqualOrParent(worktreeRoot, fullProjectRoot)) + return null; + + var relativeProjectRoot = FileIndexer.NormalizePathSeparators( + Path.GetRelativePath(worktreeRoot, fullProjectRoot)); + var projectPrefix = relativeProjectRoot is "." or "" + ? string.Empty + : relativeProjectRoot.TrimEnd('/') + "/"; + var rebased = new HashSet(StringComparer.Ordinal); + foreach (var repositoryPath in repositoryPaths) + { + if (projectPrefix.Length > 0 + && !repositoryPath.StartsWith(projectPrefix, StringComparison.Ordinal)) + { + continue; + } + + var projectPath = projectPrefix.Length == 0 + ? repositoryPath + : repositoryPath[projectPrefix.Length..]; + if (projectPath.Length == 0 && repositoryPath.EndsWith("/", StringComparison.Ordinal)) + projectPath = "/"; + if (projectPath.Length > 0) + rebased.Add(projectPath); + } + + return rebased; + } + + private static bool TryReadLooseGitReference( + string gitDirectory, + string referenceName, + out string objectId) + { + objectId = string.Empty; + if (!TryResolveGitMetadataPath(gitDirectory, referenceName, out var referencePath) + || !PathCasing.IsPathEqualOrParent(gitDirectory, referencePath) + || PathCasing.PathsEqual(gitDirectory, referencePath) + || !TryValidateGitMetadataEntry(referencePath, expectDirectory: false, out referencePath)) + { + return false; + } + + var value = DataDirectorySecurity.ReadTextWithinLimit( + LongPath.EnsureWindowsPrefix(referencePath), + MaxGitMetadataFileBytes)?.Trim(); + if (!IsFullGitObjectId(value)) + return false; + objectId = value!; + return true; + } + + private static string? TryReadPackedGitReference(string commonDirectory, string referenceName) + { + if (!TryResolveGitMetadataChildPath( + commonDirectory, + "packed-refs", + expectDirectory: false, + allowMissing: true, + out var packedRefsPath) + || !File.Exists(LongPath.EnsureWindowsPrefix(packedRefsPath))) + { + return null; + } + + var content = DataDirectorySecurity.ReadTextWithinLimit( + LongPath.EnsureWindowsPrefix(packedRefsPath), + MaxDoctorPackedRefsBytes); + if (content == null) + return null; + + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + var separator = line.IndexOf(' '); + if (separator <= 0 + || !string.Equals(line[(separator + 1)..], referenceName, StringComparison.Ordinal)) + { + continue; + } + + var objectId = line[..separator]; + return IsFullGitObjectId(objectId) ? objectId : null; + } + + return null; + } + + private static bool IsSafeGitReferenceName(string value) + => value.StartsWith("refs/", StringComparison.Ordinal) + && !value.EndsWith("/", StringComparison.Ordinal) + && !value.Contains("..", StringComparison.Ordinal) + && !value.Contains("//", StringComparison.Ordinal) + && !value.Contains('\\') + && !value.Any(char.IsControl); + + private static bool IsFullGitObjectId(string? value) + => value is { Length: 40 or 64 } && value.All(Uri.IsHexDigit); + + private static bool TryReadGitIndexVarInt( + byte[] bytes, + int contentLength, + ref int offset, + out int value) + { + value = 0; + if (offset >= contentLength) + return false; + + var current = bytes[offset++]; + value = current & 0x7f; + while ((current & 0x80) != 0) + { + if (offset >= contentLength || value > MaxDoctorGitIndexPathBytes) + return false; + current = bytes[offset++]; + value = checked(((value + 1) << 7) | (current & 0x7f)); + } + + return value <= MaxDoctorGitIndexPathBytes; + } + + private static bool TryReadGitIndexPath( + byte[] bytes, + int contentLength, + ref int offset, + int? expectedLength, + out byte[] pathBytes) + { + pathBytes = []; + var start = offset; + var length = expectedLength ?? 0; + if (expectedLength.HasValue) + { + if (length > MaxDoctorGitIndexPathBytes + || offset > contentLength - length - 1 + || bytes[offset + length] != 0) + { + return false; + } + offset += length + 1; + } + else + { + while (offset < contentLength + && bytes[offset] != 0 + && offset - start <= MaxDoctorGitIndexPathBytes) + { + offset++; + } + if (offset >= contentLength + || bytes[offset] != 0 + || offset - start > MaxDoctorGitIndexPathBytes) + { + return false; + } + length = offset - start; + offset++; + } + + if (length == 0) + return false; + pathBytes = bytes.AsSpan(start, length).ToArray(); + return true; + } +} diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index ea9745ea9..f1c0828d0 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -10,6 +10,15 @@ namespace CodeIndex.Cli; +internal sealed record HookDoctorSnapshot( + string WorktreeRoot, + string RepositoryType, + string TargetScope, + string HookStatus, + string? ManagedState, + string? ExecutableStatus, + string HookPath); + public static class HookCommandRunner { private const string HookName = "pre-commit"; @@ -32,6 +41,87 @@ public static class HookCommandRunner internal static Action? ReplaceFileForTesting { get; set; } internal static Func? ExecutableSelectionForTesting { get; set; } + internal static HookDoctorSnapshot? CaptureDoctorSnapshot(string projectPath, string appVersion) + { + var fullProjectPath = GitHelper.TryFindWorktreeRootWithoutProcess(projectPath); + if (fullProjectPath == null) + return null; + var dotGitPath = Path.Combine(fullProjectPath, ".git"); + var linkedWorktree = File.Exists(LongPath.EnsureWindowsPrefix(dotGitPath)); + if (!linkedWorktree && !Directory.Exists(LongPath.EnsureWindowsPrefix(dotGitPath))) + return null; + + var gitDir = GitHelper.ResolveGitCommonDir(fullProjectPath); + if (gitDir == null + || !GitHelper.TryResolveGitMetadataChildPath( + gitDir, + "hooks", + expectDirectory: true, + allowMissing: true, + out var hooksDir)) + { + return null; + } + + var hookPath = Path.Combine(hooksDir, HookName); + var chainedHookPath = Path.Combine(hooksDir, ChainedHookName); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(hooksDir))) + { + if (!GitHelper.TryResolveGitMetadataChildPath( + hooksDir, + HookName, + expectDirectory: false, + allowMissing: true, + out hookPath) + || !GitHelper.TryResolveGitMetadataChildPath( + hooksDir, + ChainedHookName, + expectDirectory: false, + allowMissing: true, + out chainedHookPath)) + { + return null; + } + } + + var ioHookPath = LongPath.EnsureWindowsPrefix(hookPath); + var hookExists = File.Exists(ioHookPath); + var hookContent = hookExists ? ReadHookBytesWithinLimit(ioHookPath) : null; + var analysis = AnalyzeManagedHook(hookContent); + var installed = hookExists && analysis.State == "managed"; + var hookStatus = installed ? "installed" : hookExists ? "custom" : "absent"; + string? managedState = installed ? analysis.State : null; + string? executableStatus = null; + if (installed) + { + if (!TryReadExecutableManifest(hookContent, out var installedSelection) + || !TryAnalyzeManagedInvocation( + hookContent, + fullProjectPath, + chainedHookPath, + installedSelection, + out managedState)) + { + managedState = "executable_manifest_unresolved"; + executableStatus = "unresolved"; + } + else + { + _ = TryResolveCurrentExecutable(appVersion, out var currentSelection, out _); + executableStatus = InspectExecutable(installedSelection, currentSelection).Status; + } + } + + return new HookDoctorSnapshot( + fullProjectPath, + linkedWorktree ? "linked_worktree" : "standalone_repository", + linkedWorktree ? "shared_common_dir" : "repository", + hookStatus, + managedState, + executableStatus, + hookPath); + } + public static int Run(string[] args, JsonSerializerOptions jsonOptions, string? appVersion = null) { appVersion ??= ConsoleUi.LoadVersion(); diff --git a/src/CodeIndex/Cli/IndexFreshnessChecker.cs b/src/CodeIndex/Cli/IndexFreshnessChecker.cs index 38053a611..07dbc114b 100644 --- a/src/CodeIndex/Cli/IndexFreshnessChecker.cs +++ b/src/CodeIndex/Cli/IndexFreshnessChecker.cs @@ -14,7 +14,12 @@ internal static IndexFreshnessCheckResult Check( string? projectRoot, CancellationToken cancellationToken = default, bool? pathCaseSensitive = null, - string? internalIndexDatabasePath = null) + string? internalIndexDatabasePath = null, + bool allowGitCommands = true, + HashSet? knownSkipWorktreePaths = null, + bool knownSkipWorktreePathsComplete = true, + string? knownWorkspaceHeadCommit = null, + string? knownRepositoryRoot = null) { if (string.IsNullOrWhiteSpace(projectRoot)) { @@ -28,7 +33,9 @@ internal static IndexFreshnessCheckResult Check( var indexedHeadCommit = reader.GetMetaString(DbContext.IndexedHeadCommitMetaKey); var workspaceVerifiedHeadSha = reader.GetMetaString(DbContext.WorkspaceVerifiedHeadShaMetaKey); - var workspaceHeadCommit = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken); + var workspaceHeadCommit = allowGitCommands + ? GitHelper.TryGetHeadCommit(projectRoot, cancellationToken) + : knownWorkspaceHeadCommit; var comparisonHead = string.IsNullOrWhiteSpace(workspaceVerifiedHeadSha) ? indexedHeadCommit : workspaceVerifiedHeadSha; @@ -41,6 +48,10 @@ internal static IndexFreshnessCheckResult Check( var headChanged = !string.IsNullOrWhiteSpace(comparisonHead) && !string.IsNullOrWhiteSpace(workspaceHeadCommit) && !string.Equals(comparisonHead, workspaceHeadCommit, StringComparison.Ordinal); + var headEvidenceUnavailable = !allowGitCommands + && !string.IsNullOrWhiteSpace(knownRepositoryRoot) + && !string.IsNullOrWhiteSpace(comparisonHead) + && string.IsNullOrWhiteSpace(workspaceHeadCommit); var result = new IndexFreshnessCheckResult { IndexedHeadCommit = string.IsNullOrWhiteSpace(comparisonHead) ? null : comparisonHead, @@ -50,10 +61,14 @@ internal static IndexFreshnessCheckResult Check( var ignoreCase = pathCaseSensitive.HasValue ? !pathCaseSensitive.Value - : GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken); + : allowGitCommands + ? GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken) + : PathCasing.IsIgnoreCase(projectRoot); if (pathCaseSensitive.HasValue) PathCasing.SeedFromWorkspace(projectRoot, ignoreCase); - var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(projectRoot, cancellationToken) ?? Path.GetFullPath(projectRoot); + var ignoreRuleRoot = allowGitCommands + ? GitHelper.TryGetRepositoryRoot(projectRoot, cancellationToken) ?? Path.GetFullPath(projectRoot) + : knownRepositoryRoot ?? Path.GetFullPath(projectRoot); var symlinkPolicy = ReadIndexedSymlinkPolicy(reader); var indexer = new FileIndexer( projectRoot, @@ -76,7 +91,8 @@ internal static IndexFreshnessCheckResult Check( using var indexedEnumerator = reader.EnumerateIndexedFileSnapshots().GetEnumerator(); var hasIndexed = MoveNextIndexed(); var skipWorktreePathsLoaded = false; - HashSet? skipWorktreePaths = null; + HashSet? skipWorktreePaths = knownSkipWorktreePaths; + var skipWorktreeEvidenceUnavailable = false; var workspaceFileTargets = scan.Files .Select(path => WorkspaceFileTarget.Create(projectRoot, path)) @@ -142,14 +158,20 @@ internal static IndexFreshnessCheckResult Check( hasIndexed = MoveNextIndexed(); } - result.Checked = result.ScanErrorCount == 0; + result.Checked = result.ScanErrorCount == 0 + && !headEvidenceUnavailable + && !skipWorktreeEvidenceUnavailable; result.MatchesWorkspace = result.Checked && !result.HeadChanged && result.ChangedFileCount == 0 && result.MissingFileCount == 0 && result.UnindexedFileCount == 0 && result.UnverifiableFileCount == 0; - result.Reason = BuildReason(result); + result.Reason = headEvidenceUnavailable + ? "head_unavailable" + : skipWorktreeEvidenceUnavailable + ? "skip_worktree_metadata_unavailable" + : BuildReason(result); return result; bool MoveNextIndexed() @@ -170,15 +192,26 @@ void AddMissingIndexedPath(string path) // 不要な rebuild トリガーを止める。 if (!skipWorktreePathsLoaded) { - skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(projectRoot, cancellationToken); + skipWorktreePaths = allowGitCommands + ? GitHelper.TryGetSkipWorktreePaths(projectRoot, cancellationToken) + : knownSkipWorktreePaths; skipWorktreePathsLoaded = true; } - if (skipWorktreePaths != null && skipWorktreePaths.Contains(path)) + if (skipWorktreePaths != null && IsSkipWorktreePath(skipWorktreePaths, path)) { result.OutsideSparseConeFileCount++; AddSample(result.OutsideSparseConeFiles, path); } + else if (!allowGitCommands && !knownSkipWorktreePathsComplete) + { + // A split/corrupt/unsupported index cannot prove that an indexed-but-absent path + // is a real deletion. Keep the readiness result unavailable instead of emitting a + // false missing-file diagnosis. + // split/corrupt/未対応 index では、disk 上にない indexed path が実際の削除かを + // 証明できない。誤った missing-file 判定を返さず readiness を unavailable にする。 + skipWorktreeEvidenceUnavailable = true; + } else { result.MissingFileCount++; @@ -187,6 +220,20 @@ void AddMissingIndexedPath(string path) } } + private static bool IsSkipWorktreePath(HashSet skipWorktreePaths, string path) + { + if (skipWorktreePaths.Contains(path) || skipWorktreePaths.Contains("/")) + return true; + + for (var index = path.IndexOf('/'); index >= 0; index = path.IndexOf('/', index + 1)) + { + if (skipWorktreePaths.Contains(path[..(index + 1)])) + return true; + } + + return false; + } + private static FileIndexer.SymlinkPolicy ReadIndexedSymlinkPolicy(DbReader reader) { var raw = reader.GetMetaString(DbContext.IndexedFollowSymlinksPolicyMetaKey); diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index e9779430e..6ea14a58a 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -152,7 +152,7 @@ internal static bool ShouldPollAncestorIgnorePathsForTesting( && attempt == 0 && hasAncestorIgnorePaths; - private static string ResolveWatchBackendName() + internal static string ResolveWatchBackendName() => OperatingSystem.IsMacOS() ? "fsevents" : OperatingSystem.IsLinux() diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 54b01822c..36e413821 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -135,6 +135,107 @@ internal sealed record DoctorJsonResult( [property: JsonPropertyName("environment_inventory")] IReadOnlyList? EnvironmentInventory, [property: JsonPropertyName("redaction")] DoctorRedactionJsonResult Redaction) : IVersionedJsonResult; +internal sealed record DoctorIntegrationsJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("schema_version")] string SchemaVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("check")] bool Check, + [property: JsonPropertyName("project")] DoctorIntegrationProjectJsonResult Project, + [property: JsonPropertyName("hook")] DoctorIntegrationHookJsonResult Hook, + [property: JsonPropertyName("mcp")] DoctorIntegrationMcpJsonResult Mcp, + [property: JsonPropertyName("lsp")] DoctorIntegrationLspJsonResult Lsp, + [property: JsonPropertyName("watch")] DoctorIntegrationWatchJsonResult Watch, + [property: JsonPropertyName("extensions")] DoctorIntegrationExtensionsJsonResult Extensions, + [property: JsonPropertyName("redaction")] DoctorRedactionJsonResult Redaction) : IVersionedJsonResult; + +internal sealed record DoctorIntegrationProjectJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("project_root")] string ProjectRoot, + [property: JsonPropertyName("database_path")] string DatabasePath, + [property: JsonPropertyName("database_source")] string DatabaseSource, + [property: JsonPropertyName("database_exists")] bool DatabaseExists, + [property: JsonPropertyName("schema_compatible")] bool? SchemaCompatible, + [property: JsonPropertyName("index_complete")] bool? IndexComplete, + [property: JsonPropertyName("index_incomplete_reasons")] IReadOnlyList IndexIncompleteReasons, + [property: JsonPropertyName("index_incomplete_reasons_omitted")] int IndexIncompleteReasonsOmitted, + [property: JsonPropertyName("index_incomplete_reasons_truncated")] bool IndexIncompleteReasonsTruncated, + [property: JsonPropertyName("migration_in_progress")] bool? MigrationInProgress, + [property: JsonPropertyName("index_newer_than_reader")] bool? IndexNewerThanReader, + [property: JsonPropertyName("indexed_at")] DateTime? IndexedAt, + [property: JsonPropertyName("index_matches_workspace")] bool? IndexMatchesWorkspace, + [property: JsonPropertyName("freshness_reason")] string? FreshnessReason, + [property: JsonPropertyName("workspace")] DoctorIntegrationWorkspaceJsonResult Workspace, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + +internal sealed record DoctorIntegrationWorkspaceJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("manifest_path")] string? ManifestPath, + [property: JsonPropertyName("root")] string? Root, + [property: JsonPropertyName("index_strategy")] string? IndexStrategy, + [property: JsonPropertyName("member_count")] int MemberCount, + [property: JsonPropertyName("members")] IReadOnlyList Members, + [property: JsonPropertyName("members_omitted")] int MembersOmitted, + [property: JsonPropertyName("members_truncated")] bool MembersTruncated); + +internal sealed record DoctorIntegrationHookJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("repository_type")] string RepositoryType, + [property: JsonPropertyName("target_scope")] string TargetScope, + [property: JsonPropertyName("hook_status")] string HookStatus, + [property: JsonPropertyName("managed_state")] string? ManagedState, + [property: JsonPropertyName("executable_status")] string? ExecutableStatus, + [property: JsonPropertyName("current_worktree")] string CurrentWorktree, + [property: JsonPropertyName("hook_path")] string? HookPath, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + +internal sealed record DoctorIntegrationMcpTransportJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("transport")] string Transport, + [property: JsonPropertyName("auth_configured")] bool AuthConfigured, + [property: JsonPropertyName("auth_source")] string? AuthSource, + [property: JsonPropertyName("audit_status")] string AuditStatus, + [property: JsonPropertyName("audit_source")] string AuditSource, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + +internal sealed record DoctorIntegrationMcpJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("transports")] IReadOnlyList Transports); + +internal sealed record DoctorIntegrationLspJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("configured")] bool Configured, + [property: JsonPropertyName("database_path")] string DatabasePath, + [property: JsonPropertyName("launch_command")] string LaunchCommand, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + +internal sealed record DoctorIntegrationWatchJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("preferred_backend")] string PreferredBackend, + [property: JsonPropertyName("available")] bool Available, + [property: JsonPropertyName("fallback_backend")] string? FallbackBackend, + [property: JsonPropertyName("fallback_reason")] string? FallbackReason, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + +internal sealed record DoctorIntegrationExtensionsJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("workspace_plugins_trusted")] bool WorkspacePluginsTrusted, + [property: JsonPropertyName("plugin_candidates")] int PluginCandidates, + [property: JsonPropertyName("pattern_config_candidates")] int PatternConfigCandidates, + [property: JsonPropertyName("loaded_plugin_assemblies")] int LoadedPluginAssemblies, + [property: JsonPropertyName("loaded_pattern_configs")] int LoadedPatternConfigs, + [property: JsonPropertyName("diagnostic_count")] int DiagnosticCount, + [property: JsonPropertyName("diagnostics")] IReadOnlyList Diagnostics, + [property: JsonPropertyName("diagnostics_omitted")] int DiagnosticsOmitted, + [property: JsonPropertyName("diagnostics_truncated")] bool DiagnosticsTruncated, + [property: JsonPropertyName("remediation")] IReadOnlyList Remediation); + internal sealed record LicenseJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("license")] LicenseTermsJsonResult License, @@ -1361,6 +1462,7 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(DoctorDisplayTerminalHintJsonResult))] [JsonSerializable(typeof(DoctorDisplayTruncationJsonResult))] [JsonSerializable(typeof(DoctorEnvironmentVariableJsonResult))] +[JsonSerializable(typeof(DoctorIntegrationsJsonResult))] [JsonSerializable(typeof(DoctorJsonResult))] [JsonSerializable(typeof(DoctorPathsJsonResult))] [JsonSerializable(typeof(DoctorRedactionJsonResult))] diff --git a/src/CodeIndex/Cli/ProgramRunner.Doctor.Integrations.cs b/src/CodeIndex/Cli/ProgramRunner.Doctor.Integrations.cs new file mode 100644 index 000000000..c2c125651 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Doctor.Integrations.cs @@ -0,0 +1,663 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Mcp; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private const int DoctorIntegrationMemberLimit = 20; + private const int DoctorIntegrationDiagnosticLimit = 20; + private const int DoctorIntegrationConfigurationCandidateLimit = 128; + + private static int RunDoctorIntegrations( + string appVersion, + JsonSerializerOptions jsonOptions, + bool json, + bool redactPaths, + bool check, + int? maxJsonBytes) + { + var cwd = Path.GetFullPath(Environment.CurrentDirectory); + var dbResolution = DbPathResolver.ResolveForQuery(cwd, explicitDbPath: null, explicitDataDir: null); + var workspace = BuildDoctorIntegrationWorkspace(cwd, redactPaths, out var workspaceRoot, out var workspaceScopeWarning); + var project = BuildDoctorIntegrationProject( + workspaceRoot, + workspace, + workspaceScopeWarning, + dbResolution, + redactPaths, + out var resolvedProjectRoot); + var hook = BuildDoctorIntegrationHook(cwd, appVersion, redactPaths); + var mcp = BuildDoctorIntegrationMcp(); + var lsp = BuildDoctorIntegrationLsp(project, dbResolution, redactPaths); + var watch = BuildDoctorIntegrationWatch(); + var extensions = BuildDoctorIntegrationExtensions(resolvedProjectRoot); + var overall = ResolveDoctorIntegrationOverallStatus( + project.Status, + hook.Status, + mcp.Status, + lsp.Status, + watch.Status, + extensions.Status); + var payload = new DoctorIntegrationsJsonResult( + ApiVersion: JsonOutputContract.ApiVersion, + SchemaVersion: "1", + Status: overall, + Check: check, + Project: project, + Hook: hook, + Mcp: mcp, + Lsp: lsp, + Watch: watch, + Extensions: extensions, + Redaction: new DoctorRedactionJsonResult(redactPaths, SecretsRedacted: true)); + + if (json) + { + var serialized = JsonSerializer.Serialize( + payload, + CliJsonSerializerContextFactory.Create(jsonOptions).DoctorIntegrationsJsonResult); + var byteCount = Encoding.UTF8.GetByteCount(serialized) + Encoding.UTF8.GetByteCount(Environment.NewLine); + if (maxJsonBytes.HasValue && byteCount > maxJsonBytes.Value) + { + return CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + $"doctor integration JSON output is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes and exceeds --max-json-bytes {maxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)}.", + CommandExitCodes.UsageError, + "increase --max-json-bytes; integration arrays and diagnostics are already bounded.", + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); + } + + Console.WriteLine(serialized); + } + else + { + WriteDoctorIntegrationText(payload); + } + + return check && overall is "warning" or "error" + ? CommandExitCodes.StaleIndex + : CommandExitCodes.Success; + } + + private static DoctorIntegrationWorkspaceJsonResult BuildDoctorIntegrationWorkspace( + string cwd, + bool redactPaths, + out string workspaceRoot, + out bool scopeWarning) + { + scopeWarning = false; + try + { + var manifest = WorkspaceManifestLoader.Find(cwd); + if (manifest == null) + { + workspaceRoot = cwd; + return new DoctorIntegrationWorkspaceJsonResult( + "not_applicable", + null, + null, + null, + 0, + [], + 0, + false); + } + + workspaceRoot = manifest.Root; + var members = manifest.Members + .Take(DoctorIntegrationMemberLimit) + .Select(member => RedactDoctorPath(member.Path, redactPaths)) + .ToArray(); + var isMember = string.Equals(manifest.IndexStrategy, "single", StringComparison.OrdinalIgnoreCase) + ? PathCasing.IsPathEqualOrParent(manifest.Root, cwd) + : manifest.Members.Any(member => PathCasing.IsPathEqualOrParent(member.Path, cwd)); + scopeWarning = manifest.Members.Count > 0 && !isMember; + var omitted = Math.Max(0, manifest.Members.Count - members.Length); + return new DoctorIntegrationWorkspaceJsonResult( + scopeWarning ? "warning" : "ready", + RedactDoctorPath(manifest.Path, redactPaths), + RedactDoctorPath(manifest.Root, redactPaths), + manifest.IndexStrategy, + manifest.Members.Count, + members, + omitted, + omitted > 0); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or ArgumentException or NotSupportedException) + { + workspaceRoot = cwd; + scopeWarning = true; + return new DoctorIntegrationWorkspaceJsonResult( + "warning", + null, + null, + null, + 0, + [RedactDoctorPath(CommandErrorWriter.FormatSanitizedException(ex), redactPaths)], + 0, + false); + } + } + + private static DoctorIntegrationProjectJsonResult BuildDoctorIntegrationProject( + string workspaceRoot, + DoctorIntegrationWorkspaceJsonResult workspace, + bool workspaceScopeWarning, + DbPathResolution dbResolution, + bool redactPaths, + out string resolvedProjectRoot) + { + resolvedProjectRoot = workspaceRoot; + var displayRoot = RedactDoctorPath(workspaceRoot, redactPaths); + var displayDb = RedactDoctorPath(dbResolution.DbPath, redactPaths); + var dbSource = dbResolution.DataDirSource ?? "explicit_db"; + if (!File.Exists(LongPath.EnsureWindowsPrefix(dbResolution.DbPath))) + { + return new DoctorIntegrationProjectJsonResult( + "error", + "database_missing", + displayRoot, + displayDb, + dbSource, + false, + null, + null, + [], + 0, + false, + null, + null, + null, + null, + "database_missing", + workspace, + ["cdidx index ."]); + } + + try + { + using var configurationScope = ExtractorPluginRegistry.BeginAuthorizedConfigurationScope(); + using var db = new DbContext(DbOpenIntent.QueryOnly, dbResolution.DbPath); + if (!db.TryValidateIsCodeIndexDb(out _)) + { + return new DoctorIntegrationProjectJsonResult( + "error", + "schema_incompatible", + displayRoot, + displayDb, + dbSource, + true, + false, + null, + [], + 0, + false, + null, + null, + null, + null, + "schema_incompatible", + workspace, + ["cdidx index . --rebuild --yes"]); + } + + var reader = new DbReader(db); + var status = reader.GetStatus(includeDatabaseSizeAttribution: false); + var projectRoot = string.IsNullOrWhiteSpace(status.ProjectRoot) ? workspaceRoot : status.ProjectRoot; + resolvedProjectRoot = projectRoot; + var repositoryRoot = GitHelper.TryFindWorktreeRootWithoutProcess(projectRoot); + var workspaceHeadCommit = repositoryRoot == null + ? null + : GitHelper.TryReadHeadCommitWithoutProcess(projectRoot); + var skipWorktreePaths = GitHelper.TryReadSkipWorktreePathsWithoutProcess(projectRoot); + var freshness = IndexFreshnessChecker.Check( + reader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbResolution.DbPath), + allowGitCommands: false, + knownSkipWorktreePaths: skipWorktreePaths, + knownSkipWorktreePathsComplete: repositoryRoot == null || skipWorktreePaths != null, + knownWorkspaceHeadCommit: workspaceHeadCommit, + knownRepositoryRoot: repositoryRoot); + var matches = freshness.Checked && freshness.MatchesWorkspace; + var persistedReadinessWarning = !status.IndexComplete + || status.MigrationInProgress + || status.IndexNewerThanReader; + var resultStatus = !freshness.Checked || workspaceScopeWarning || persistedReadinessWarning + ? "warning" + : matches + ? "ready" + : "warning"; + var reason = workspaceScopeWarning + ? "workspace_scope_mismatch" + : status.MigrationInProgress + ? "migration_in_progress" + : status.IndexNewerThanReader + ? "index_newer_than_reader" + : !status.IndexComplete + ? "index_incomplete" + : !freshness.Checked + ? freshness.Reason ?? "freshness_unavailable" + : matches + ? "index_fresh" + : freshness.Reason ?? "index_stale"; + var incompleteReasons = (status.IndexIncompleteReasons ?? []) + .Take(DoctorIntegrationDiagnosticLimit) + .ToArray(); + var incompleteReasonsOmitted = Math.Max( + 0, + (status.IndexIncompleteReasons?.Count ?? 0) - incompleteReasons.Length); + return new DoctorIntegrationProjectJsonResult( + resultStatus, + reason, + RedactDoctorPath(projectRoot, redactPaths), + displayDb, + dbSource, + true, + true, + status.IndexComplete, + incompleteReasons, + incompleteReasonsOmitted, + incompleteReasonsOmitted > 0, + status.MigrationInProgress, + status.IndexNewerThanReader, + status.IndexedAt, + freshness.Checked ? freshness.MatchesWorkspace : null, + freshness.Reason, + workspace, + matches && !workspaceScopeWarning && !persistedReadinessWarning + ? [] + : ["cdidx status --check", "cdidx status --json", "cdidx index ."]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or Microsoft.Data.Sqlite.SqliteException or InvalidOperationException) + { + return new DoctorIntegrationProjectJsonResult( + "error", + "database_unreadable", + displayRoot, + displayDb, + dbSource, + true, + false, + null, + [], + 0, + false, + null, + null, + null, + null, + RedactDoctorPath(CommandErrorWriter.FormatSanitizedException(ex), redactPaths), + workspace, + ["cdidx status --check", "cdidx index . --rebuild --yes"]); + } + } + + private static DoctorIntegrationHookJsonResult BuildDoctorIntegrationHook( + string projectRoot, + string appVersion, + bool redactPaths) + { + try + { + var snapshot = HookCommandRunner.CaptureDoctorSnapshot(projectRoot, appVersion); + if (snapshot == null) + { + return new DoctorIntegrationHookJsonResult( + "not_applicable", + "not_a_git_repository", + "none", + "none", + "absent", + null, + null, + RedactDoctorPath(projectRoot, redactPaths), + null, + []); + } + + var status = snapshot.HookStatus switch + { + "installed" when snapshot.ManagedState == "managed" && snapshot.ExecutableStatus is null or "available" => "ready", + "absent" => "not_configured", + _ => "warning", + }; + var reason = ResolveDoctorIntegrationHookReason(status, snapshot); + return new DoctorIntegrationHookJsonResult( + status, + reason, + snapshot.RepositoryType, + snapshot.TargetScope, + snapshot.HookStatus, + snapshot.ManagedState, + snapshot.ExecutableStatus, + RedactDoctorPath(snapshot.WorktreeRoot, redactPaths), + RedactDoctorPath(snapshot.HookPath, redactPaths), + status == "ready" ? [] : ["cdidx hooks status", "cdidx hooks install"]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException or NotSupportedException) + { + return new DoctorIntegrationHookJsonResult( + "warning", + "hook_status_unavailable", + "unknown", + "unknown", + "unknown", + null, + null, + RedactDoctorPath(projectRoot, redactPaths), + null, + ["cdidx hooks status", RedactDoctorPath(CommandErrorWriter.FormatSanitizedException(ex), redactPaths)]); + } + } + + internal static string ResolveDoctorIntegrationHookReason( + string status, + HookDoctorSnapshot snapshot) + => status switch + { + "ready" => "managed_hook_current", + "not_configured" => "hook_absent", + _ when snapshot.HookStatus == "custom" => "custom_hook_present", + _ when snapshot.ExecutableStatus is not null and not "available" => snapshot.ExecutableStatus, + _ => snapshot.ManagedState ?? "hook_requires_attention", + }; + + private static DoctorIntegrationMcpJsonResult BuildDoctorIntegrationMcp() + { + var stdioToken = ReadDoctorMcpToken(McpAuthenticatorFactory.AuthTokenEnvVar); + var httpToken = ReadDoctorMcpToken("CDIDX_MCP_HTTP_TOKEN", McpAuthenticatorFactory.AuthTokenEnvVar); + var invalid = !stdioToken.Valid || !httpToken.Valid; + return new DoctorIntegrationMcpJsonResult( + invalid ? "error" : "ready", + invalid ? "invalid_auth_configuration" : "stdio_ready_http_optional", + [ + new DoctorIntegrationMcpTransportJsonResult( + stdioToken.Valid ? "ready" : "error", + !stdioToken.Valid ? "invalid_auth_token" : stdioToken.Configured ? "auth_configured" : "local_stdio", + "stdio", + stdioToken.Configured, + stdioToken.Source, + "not_configured", + "cli_option", + ["cdidx mcp"]), + new DoctorIntegrationMcpTransportJsonResult( + !httpToken.Valid ? "error" : httpToken.Configured ? "ready" : "not_configured", + !httpToken.Valid ? "invalid_auth_token" : httpToken.Configured ? "auth_configured" : "auth_not_configured", + "http", + httpToken.Configured, + httpToken.Source, + "not_configured", + "cli_option", + ["set CDIDX_MCP_HTTP_TOKEN", "cdidx mcp --transport http"]), + ]); + } + + private static DoctorMcpTokenReadiness ReadDoctorMcpToken(params string[] environmentVariables) + { + foreach (var environmentVariable in environmentVariables) + { + var raw = CdidxEnvironment.GetProcessEnvironmentVariable(environmentVariable); + if (string.IsNullOrEmpty(raw)) + continue; + try + { + _ = McpEnvironment.GetOptionalToken(environmentVariable); + return new DoctorMcpTokenReadiness(true, true, environmentVariable); + } + catch (FormatException) + { + return new DoctorMcpTokenReadiness(true, false, environmentVariable); + } + } + + return new DoctorMcpTokenReadiness(false, true, null); + } + + private readonly record struct DoctorMcpTokenReadiness(bool Configured, bool Valid, string? Source); + + private static DoctorIntegrationLspJsonResult BuildDoctorIntegrationLsp( + DoctorIntegrationProjectJsonResult project, + DbPathResolution dbResolution, + bool redactPaths) + { + var ready = project.DatabaseExists && project.SchemaCompatible == true; + return new DoctorIntegrationLspJsonResult( + ready ? "ready" : "error", + ready ? "database_resolved" : project.Reason, + ready, + RedactDoctorPath(dbResolution.DbPath, redactPaths), + "cdidx lsp", + ready ? [] : ["cdidx index .", "cdidx lsp"]); + } + + private static DoctorIntegrationWatchJsonResult BuildDoctorIntegrationWatch() + { + var preferred = IndexWatchRunner.ResolveWatchBackendName(); + var platformSupported = OperatingSystem.IsMacOS() + || OperatingSystem.IsLinux() + || OperatingSystem.IsWindows(); + return new DoctorIntegrationWatchJsonResult( + platformSupported ? "ready" : "warning", + platformSupported ? "platform_backend_available" : "generic_filesystem_watcher", + preferred, + true, + OperatingSystem.IsMacOS() ? "polling" : null, + null, + ["cdidx index . --watch"]); + } + + private static DoctorIntegrationExtensionsJsonResult BuildDoctorIntegrationExtensions(string projectRoot) + { + var diagnostics = new List(); + var pluginCandidates = CountDoctorIntegrationCandidates( + Path.Combine(projectRoot, ".cdidx", "plugins"), + ["*.dll"], + diagnostics); + var patternCandidates = CountDoctorIntegrationCandidates( + Path.Combine(projectRoot, ".cdidx", "patterns"), + ["*.yaml", "*.yml"], + diagnostics); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + pluginCandidates += CountDoctorIntegrationCandidates( + Path.Combine(home, ".cdidx", "plugins"), + ["*.dll"], + diagnostics); + patternCandidates += CountDoctorIntegrationCandidates( + Path.Combine(home, ".config", "cdidx", "patterns"), + ["*.yaml", "*.yml"], + diagnostics); + } + + var runtime = ExtractorPluginRegistry.CaptureDoctorRuntimeSnapshot(projectRoot); + var trusted = ExtractorPluginRegistry.WorkspacePluginsTrustedForDoctor(); + var configured = pluginCandidates + patternCandidates > 0; + var loaded = runtime.LoadedPluginAssemblies + runtime.LoadedPatternConfigs > 0; + var diagnosticCount = runtime.DiagnosticCount + diagnostics.Count; + var status = diagnosticCount > 0 + ? "warning" + : configured && !loaded + ? "warning" + : loaded + ? "ready" + : "not_configured"; + var reason = diagnosticCount > 0 + ? "configuration_diagnostics" + : configured && !loaded + ? "configured_not_loaded" + : loaded + ? "extensions_loaded" + : "no_extension_configuration"; + var boundedDiagnostics = runtime.Diagnostics + .Concat(diagnostics) + .Select(static diagnostic => DiagnosticRedactor.RedactSensitiveText( + diagnostic, + "[redacted]", + redactPaths: true)) + .Take(DoctorIntegrationDiagnosticLimit) + .ToArray(); + var omitted = Math.Max(0, diagnosticCount - boundedDiagnostics.Length); + return new DoctorIntegrationExtensionsJsonResult( + status, + reason, + trusted, + pluginCandidates, + patternCandidates, + runtime.LoadedPluginAssemblies, + runtime.LoadedPatternConfigs, + diagnosticCount, + boundedDiagnostics, + omitted, + omitted > 0, + status is "warning" ? ["cdidx status --json"] : []); + } + + private static int CountDoctorIntegrationCandidates( + string directory, + IReadOnlyList patterns, + List diagnostics) + { + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(directory))) + return 0; + + try + { + var count = 0; + foreach (var pattern in patterns) + { + foreach (var _ in Directory.EnumerateFiles( + LongPath.EnsureWindowsPrefix(directory), + pattern, + SearchOption.TopDirectoryOnly)) + { + if (count >= DoctorIntegrationConfigurationCandidateLimit) + { + diagnostics.Add("configuration_candidate_limit_exceeded"); + return count; + } + count++; + } + } + return count; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + diagnostics.Add($"configuration_scan_failed:{ex.GetType().Name}"); + return 0; + } + } + + private static string ResolveDoctorIntegrationOverallStatus(params string[] statuses) + { + if (statuses.Contains("error", StringComparer.Ordinal)) + return "error"; + if (statuses.Contains("warning", StringComparer.Ordinal)) + return "warning"; + return "ready"; + } + + private static void WriteDoctorIntegrationText(DoctorIntegrationsJsonResult payload) + { + Console.WriteLine("cdidx integration readiness"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("status", payload.Status)); + WriteSection("project", payload.Project.Status, payload.Project.Reason); + WriteDetail("project_root", payload.Project.ProjectRoot); + WriteDetail("database_path", payload.Project.DatabasePath); + WriteDetail("database_source", payload.Project.DatabaseSource); + WriteDetail("database_exists", FormatRequiredBool(payload.Project.DatabaseExists)); + WriteDetail("schema_compatible", FormatOptionalBool(payload.Project.SchemaCompatible)); + WriteDetail("index_complete", FormatOptionalBool(payload.Project.IndexComplete)); + WriteDetail("index_matches_workspace", FormatOptionalBool(payload.Project.IndexMatchesWorkspace)); + WriteDetail("freshness_reason", payload.Project.FreshnessReason); + WriteDetail("workspace_status", payload.Project.Workspace.Status); + WriteDetail("workspace_index_strategy", payload.Project.Workspace.IndexStrategy); + WriteDetail("workspace_member_count", payload.Project.Workspace.MemberCount.ToString(CultureInfo.InvariantCulture)); + foreach (var reason in payload.Project.IndexIncompleteReasons) + WriteDetail("index_incomplete_reason", reason); + WriteRemediation(payload.Project.Remediation); + + WriteSection("hook", payload.Hook.Status, payload.Hook.Reason); + WriteDetail("repository_type", payload.Hook.RepositoryType); + WriteDetail("target_scope", payload.Hook.TargetScope); + WriteDetail("hook_status", payload.Hook.HookStatus); + WriteDetail("managed_state", payload.Hook.ManagedState); + WriteDetail("executable_status", payload.Hook.ExecutableStatus); + WriteDetail("current_worktree", payload.Hook.CurrentWorktree); + WriteDetail("hook_path", payload.Hook.HookPath); + WriteRemediation(payload.Hook.Remediation); + + WriteSection("mcp", payload.Mcp.Status, payload.Mcp.Reason); + foreach (var transport in payload.Mcp.Transports) + { + var prefix = $"{transport.Transport}_"; + WriteDetail(prefix + "status", transport.Status); + WriteDetail(prefix + "reason", transport.Reason); + WriteDetail(prefix + "auth_configured", FormatRequiredBool(transport.AuthConfigured)); + WriteDetail(prefix + "auth_source", transport.AuthSource); + WriteDetail(prefix + "audit_status", transport.AuditStatus); + WriteDetail(prefix + "audit_source", transport.AuditSource); + foreach (var command in transport.Remediation) + WriteDetail(prefix + "remediation", command); + } + + WriteSection("lsp", payload.Lsp.Status, payload.Lsp.Reason); + WriteDetail("configured", FormatRequiredBool(payload.Lsp.Configured)); + WriteDetail("database_path", payload.Lsp.DatabasePath); + WriteDetail("launch_command", payload.Lsp.LaunchCommand); + WriteRemediation(payload.Lsp.Remediation); + + WriteSection("watch", payload.Watch.Status, payload.Watch.Reason); + WriteDetail("preferred_backend", payload.Watch.PreferredBackend); + WriteDetail("available", FormatRequiredBool(payload.Watch.Available)); + WriteDetail("fallback_backend", payload.Watch.FallbackBackend); + WriteDetail("fallback_reason", payload.Watch.FallbackReason); + WriteRemediation(payload.Watch.Remediation); + + WriteSection("extensions", payload.Extensions.Status, payload.Extensions.Reason); + WriteDetail("workspace_plugins_trusted", FormatRequiredBool(payload.Extensions.WorkspacePluginsTrusted)); + WriteDetail("plugin_candidates", payload.Extensions.PluginCandidates.ToString(CultureInfo.InvariantCulture)); + WriteDetail("pattern_config_candidates", payload.Extensions.PatternConfigCandidates.ToString(CultureInfo.InvariantCulture)); + WriteDetail("loaded_plugin_assemblies", payload.Extensions.LoadedPluginAssemblies.ToString(CultureInfo.InvariantCulture)); + WriteDetail("loaded_pattern_configs", payload.Extensions.LoadedPatternConfigs.ToString(CultureInfo.InvariantCulture)); + WriteDetail("diagnostic_count", payload.Extensions.DiagnosticCount.ToString(CultureInfo.InvariantCulture)); + foreach (var diagnostic in payload.Extensions.Diagnostics) + WriteDetail("diagnostic", diagnostic); + WriteRemediation(payload.Extensions.Remediation); + + static void WriteSection(string name, string status, string reason) + { + Console.WriteLine(); + Console.WriteLine($"{name}:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("status", status, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("reason", reason, indent: " ")); + } + + static void WriteDetail(string name, string? value) + { + if (value != null) + Console.WriteLine(ConsoleUi.FormatSummaryLine(name, value, indent: " ")); + } + + static void WriteRemediation(IReadOnlyList remediation) + { + foreach (var command in remediation) + Console.WriteLine(ConsoleUi.FormatSummaryLine("remediation", command, indent: " ")); + } + + static string FormatRequiredBool(bool value) => value ? "true" : "false"; + static string? FormatOptionalBool(bool? value) + => value.HasValue ? FormatRequiredBool(value.Value) : null; + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Doctor.cs b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs index 72a29871c..31234eb0e 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Doctor.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs @@ -27,6 +27,8 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); var json = false; bool? redactPaths = null; + var integrations = false; + var check = false; var envInventory = DoctorEnvironmentInventoryMode.None; string? envDomain = null; string? envCategory = null; @@ -87,6 +89,12 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt case "--show-paths": redactPaths = false; break; + case "--integrations": + integrations = true; + break; + case "--check": + check = true; + break; case "--env-inventory": case "--env-inventory=compact": envInventory = DoctorEnvironmentInventoryMode.Compact; @@ -110,6 +118,30 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt } var filtersRequested = envDomain is not null || envCategory is not null || envSensitivity is not null; + if (integrations && (envInventory != DoctorEnvironmentInventoryMode.None || filtersRequested)) + { + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + "doctor --integrations cannot be combined with environment inventory options.", + CommandExitCodes.InvalidArgument, + "run the integration readiness summary and environment inventory as separate doctor commands.", + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); + } + if (check && !integrations) + { + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + "doctor --check requires --integrations.", + CommandExitCodes.InvalidArgument, + "use `cdidx doctor --integrations --check`.", + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); + } if (filtersRequested && envInventory != DoctorEnvironmentInventoryMode.Full) { return CommandErrorWriter.WriteJsonOrHuman( @@ -122,14 +154,14 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt errorCode: CommandErrorCodes.UsageError, command: "doctor"); } - if (maxJsonBytes.HasValue && (!json || envInventory != DoctorEnvironmentInventoryMode.Full)) + if (maxJsonBytes.HasValue && (!json || (!integrations && envInventory != DoctorEnvironmentInventoryMode.Full))) { return CommandErrorWriter.WriteJsonOrHuman( wantsJson, jsonOptions, - "doctor --max-json-bytes requires --json and --env-inventory=full.", + "doctor --max-json-bytes requires --json with --integrations or --env-inventory=full.", CommandExitCodes.InvalidArgument, - "use `cdidx doctor --json --env-inventory=full --max-json-bytes `.", + "use `cdidx doctor --integrations --json --max-json-bytes ` or `cdidx doctor --json --env-inventory=full --max-json-bytes `.", usage: GetDoctorUsage(), errorCode: CommandErrorCodes.UsageError, command: "doctor"); @@ -147,6 +179,17 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt return filterExitCode; } + if (integrations) + { + return RunDoctorIntegrations( + appVersion, + jsonOptions, + json, + redactPaths ?? true, + check, + maxJsonBytes); + } + if (json) { return WriteDoctorJson( @@ -231,7 +274,7 @@ private enum DoctorEnvironmentInventoryMode } private static string GetDoctorUsage() - => "cdidx doctor [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"; + => "cdidx doctor [--integrations [--check]] [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"; private static bool TryReadDoctorValueOption( string[] args, diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 2d022c44e..9f766d0b1 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -56,6 +56,35 @@ internal readonly record struct FoldProducerReadinessSnapshot( bool UsesOnlyBuiltInProducers, long MutationGeneration); + internal readonly record struct DoctorRuntimeSnapshot( + bool Initialized, + int LoadedPluginAssemblies, + int LoadedPatternConfigs, + int DiagnosticCount, + IReadOnlyList Diagnostics); + + internal static DoctorRuntimeSnapshot CaptureDoctorRuntimeSnapshot(string? workspaceRoot) + { + var patternSnapshot = GetPatternSnapshot(workspaceRoot); + lock (Gate) + { + var diagnostics = Diagnostics + .Concat(patternSnapshot.Diagnostics) + .Take(DiagnosticLimit) + .Select(static diagnostic => $"{diagnostic.Severity}:{diagnostic.Category}:{diagnostic.Message}") + .ToArray(); + return new DoctorRuntimeSnapshot( + Volatile.Read(ref pluginsLoaded), + pluginAssemblyCount + patternSnapshot.PluginAssemblyCount, + patternSnapshot.ConfigCount, + diagnosticTotalCount + patternSnapshot.DiagnosticTotalCount, + diagnostics); + } + } + + internal static bool WorkspacePluginsTrustedForDoctor() + => WorkspacePluginsTrusted(); + internal static IDisposable BeginAuthorizedConfigurationScope() { var previous = AuthorizedConfigurationScope.Value; diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index d1b6efae1..36696c811 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -26,6 +26,67 @@ public ExtractorPluginRegistryTests(TrustedPluginAssemblyFixture trustedPluginAs internal const string SlowPluginConstructorEnvironmentVariable = PluginIsolationFixtureEnvironment.SlowConstructor; internal const string CrashingPluginConstructorEnvironmentVariable = PluginIsolationFixtureEnvironment.CrashingConstructor; + [Fact] + public void DoctorIntegrations_DoesNotStartConfiguredPluginWorker_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor_integrations_no_worker_5102"); + var originalDirectory = Environment.CurrentDirectory; + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var pluginDirectory = Path.Combine(projectRoot, ".cdidx", "plugins"); + Directory.CreateDirectory(pluginDirectory); + File.Copy(pluginAssemblyFixturePath, Path.Combine(pluginDirectory, "configured.dll")); + for (var index = 1; index < ExtractorPluginRegistry.MaxPluginAssemblyCandidatesPerDirectory; index++) + File.WriteAllBytes(Path.Combine(pluginDirectory, $"configured-{index:D3}.dll"), []); + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + ExtractorPluginRegistry.ReloadForTests(); + ExtractorPluginRegistry.UserPluginDirectoryForTesting = pluginDirectory; + var workerStarts = 0; + ExtractorPluginWorkerClient.ProcessStartedForTesting = () => workerStarts++; + Environment.CurrentDirectory = projectRoot; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + Assert.Equal(0, workerStarts); + using var document = System.Text.Json.JsonDocument.Parse(stdout); + var extensions = document.RootElement.GetProperty("extensions"); + Assert.Equal("configured_not_loaded", extensions.GetProperty("reason").GetString()); + Assert.Equal( + ExtractorPluginRegistry.MaxPluginAssemblyCandidatesPerDirectory, + extensions.GetProperty("plugin_candidates").GetInt32()); + Assert.Equal(0, extensions.GetProperty("loaded_plugin_assemblies").GetInt32()); + Assert.Equal(0, extensions.GetProperty("diagnostic_count").GetInt32()); + + File.WriteAllBytes(Path.Combine(pluginDirectory, "overflow.dll"), []); + var (overflowExitCode, overflowStdout, overflowStderr) = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, overflowExitCode); + Assert.Empty(overflowStderr); + Assert.Equal(0, workerStarts); + using var overflowDocument = System.Text.Json.JsonDocument.Parse(overflowStdout); + var overflowExtensions = overflowDocument.RootElement.GetProperty("extensions"); + Assert.Equal("configuration_diagnostics", overflowExtensions.GetProperty("reason").GetString()); + Assert.Equal(1, overflowExtensions.GetProperty("diagnostic_count").GetInt32()); + Assert.Contains( + overflowExtensions.GetProperty("diagnostics").EnumerateArray(), + diagnostic => diagnostic.GetString() == "configuration_candidate_limit_exceeded"); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + ExtractorPluginWorkerClient.ProcessStartedForTesting = null; + ExtractorPluginRegistry.ResetForTests(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void GetAcceptedTrustOverrides_ReportsWorkspacePluginTrust_3735() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 6535bd43b..59894fc6d 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1668,6 +1668,467 @@ public void RunDoctor_TruncatesTerminalEnvironmentValues_Issue3109() Assert.DoesNotContain(tail, stdout); } + [Fact] + public void RunDoctor_IntegrationsJson_ReportsStableRedactedReadinessWithoutStartingPluginWorkers_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor-integrations"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var dataDir = Path.GetDirectoryName(dbPath)!; + const string secret = "doctor-integration-secret-5102"; + using var env = EnvironmentVariableScope.Capture( + DbPathResolver.DataDirEnvironmentVariable, + McpAuthenticatorFactory.AuthTokenEnvVar, + "CDIDX_MCP_HTTP_TOKEN"); + env.Set(DbPathResolver.DataDirEnvironmentVariable, dataDir); + env.Set(McpAuthenticatorFactory.AuthTokenEnvVar, secret); + env.Set("CDIDX_MCP_HTTP_TOKEN", secret); + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + Assert.DoesNotContain(secret, stdout, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, stdout, StringComparison.Ordinal); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal("1", root.GetProperty("api_version").GetString()); + Assert.Equal("1", root.GetProperty("schema_version").GetString()); + Assert.False(root.GetProperty("check").GetBoolean()); + Assert.True(root.GetProperty("redaction").GetProperty("paths_redacted").GetBoolean()); + Assert.True(root.GetProperty("redaction").GetProperty("secrets_redacted").GetBoolean()); + Assert.Equal("ready", root.GetProperty("project").GetProperty("status").GetString()); + Assert.True(root.GetProperty("project").GetProperty("index_complete").GetBoolean()); + Assert.True(root.GetProperty("project").GetProperty("index_matches_workspace").GetBoolean()); + Assert.Contains( + root.GetProperty("mcp").GetProperty("transports").EnumerateArray(), + transport => transport.GetProperty("transport").GetString() == "http" + && transport.GetProperty("auth_configured").GetBoolean() + && transport.GetProperty("auth_source").GetString() == "CDIDX_MCP_HTTP_TOKEN"); + Assert.True(root.GetProperty("watch").GetProperty("available").GetBoolean()); + Assert.True(root.GetProperty("extensions").TryGetProperty("diagnostics_truncated", out _)); + + env.Set("CDIDX_MCP_HTTP_TOKEN", "invalid\ntoken"); + var (invalidExitCode, invalidStdout, invalidStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, invalidExitCode); + Assert.Empty(invalidStderr); + Assert.DoesNotContain("invalid\ntoken", invalidStdout, StringComparison.Ordinal); + using var invalidDocument = JsonDocument.Parse(invalidStdout); + Assert.Equal("error", invalidDocument.RootElement.GetProperty("mcp").GetProperty("status").GetString()); + Assert.Contains( + invalidDocument.RootElement.GetProperty("mcp").GetProperty("transports").EnumerateArray(), + transport => transport.GetProperty("transport").GetString() == "http" + && transport.GetProperty("status").GetString() == "error" + && transport.GetProperty("reason").GetString() == "invalid_auth_token" + && transport.GetProperty("auth_source").GetString() == "CDIDX_MCP_HTTP_TOKEN"); + + env.Set("CDIDX_MCP_HTTP_TOKEN", secret); + var (humanExitCode, humanStdout, humanStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--show-paths"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Empty(humanStderr); + Assert.Contains(projectRoot, humanStdout, StringComparison.Ordinal); + Assert.Contains("project_root", humanStdout, StringComparison.Ordinal); + Assert.Contains("preferred_backend", humanStdout, StringComparison.Ordinal); + Assert.Contains("stdio_audit_status", humanStdout, StringComparison.Ordinal); + Assert.DoesNotContain(secret, humanStdout, StringComparison.Ordinal); + + Assert.Equal( + "unresolved", + ProgramRunner.ResolveDoctorIntegrationHookReason( + "warning", + new HookDoctorSnapshot( + projectRoot, + "standalone_repository", + "repository", + "installed", + "managed", + "unresolved", + Path.Combine(projectRoot, ".git", "hooks", "pre-commit")))); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunDoctor_IntegrationsCheck_FailsForStaleOrMissingDatabase_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor-integrations-check"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var dataDir = Path.GetDirectoryName(dbPath)!; + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, dataDir); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexCompletenessMetaKey, "incomplete"); + writer.SetMeta( + DbContext.IndexIncompleteReasonsMetaKey, + JsonSerializer.Serialize(new[] { DbReader.SymbolsOnlyIndexIncompleteReason })); + } + var (incompleteExitCode, incompleteStdout, incompleteStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.StaleIndex, incompleteExitCode); + Assert.Empty(incompleteStderr); + using (var incompleteDocument = JsonDocument.Parse(incompleteStdout)) + { + var project = incompleteDocument.RootElement.GetProperty("project"); + Assert.Equal("warning", project.GetProperty("status").GetString()); + Assert.Equal("index_incomplete", project.GetProperty("reason").GetString()); + Assert.False(project.GetProperty("index_complete").GetBoolean()); + Assert.Contains( + project.GetProperty("index_incomplete_reasons").EnumerateArray(), + item => item.GetString() == DbReader.SymbolsOnlyIndexIncompleteReason); + } + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexCompletenessMetaKey, "complete"); + writer.SetMeta(DbContext.IndexIncompleteReasonsMetaKey, string.Empty); + } + + File.WriteAllText(Path.Combine(projectRoot, "stale.cs"), "public class Stale { }"); + + var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.StaleIndex, staleExitCode); + Assert.Empty(staleStderr); + using (var staleDocument = JsonDocument.Parse(staleStdout)) + { + Assert.True(staleDocument.RootElement.GetProperty("check").GetBoolean()); + Assert.Equal("warning", staleDocument.RootElement.GetProperty("project").GetProperty("status").GetString()); + Assert.False(staleDocument.RootElement.GetProperty("project").GetProperty("index_matches_workspace").GetBoolean()); + } + + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.Combine(projectRoot, "missing-data")); + var (missingExitCode, missingStdout, missingStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.StaleIndex, missingExitCode); + Assert.Empty(missingStderr); + using var missingDocument = JsonDocument.Parse(missingStdout); + Assert.Equal("error", missingDocument.RootElement.GetProperty("status").GetString()); + Assert.Equal("database_missing", missingDocument.RootElement.GetProperty("project").GetProperty("reason").GetString()); + Assert.Equal("error", missingDocument.RootElement.GetProperty("lsp").GetProperty("status").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunDoctor_Integrations_ResolvesWorkspaceMemberAndLinkedWorktreeHookScope_Issue5102() + { + var workspaceRoot = TestProjectHelper.CreateTempProject("doctor-integrations-workspace"); + var originalDirectory = Environment.CurrentDirectory; + try + { + var memberRoot = Path.Combine(workspaceRoot, "member"); + var nestedMemberPath = Path.Combine(memberRoot, "nested"); + Directory.CreateDirectory(nestedMemberPath); + File.WriteAllText( + Path.Combine(workspaceRoot, WorkspaceManifestLoader.FileName), + "{\"index_strategy\":\"single\",\"members\":[\"member\"]}"); + var dbPath = TestProjectHelper.CreateProjectDb(workspaceRoot); + var commonGitDir = Path.Combine(workspaceRoot, "git-metadata"); + var worktreeGitDir = Path.Combine(commonGitDir, "worktrees", "member"); + Directory.CreateDirectory(Path.Combine(commonGitDir, "hooks")); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(memberRoot, ".git"), $"gitdir: {worktreeGitDir}{Environment.NewLine}"); + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), $"../..{Environment.NewLine}"); + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + Environment.CurrentDirectory = nestedMemberPath; + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json", "--show-paths"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var workspace = document.RootElement.GetProperty("project").GetProperty("workspace"); + Assert.Equal("ready", workspace.GetProperty("status").GetString()); + Assert.Equal("single", workspace.GetProperty("index_strategy").GetString()); + Assert.Equal(1, workspace.GetProperty("member_count").GetInt32()); + Assert.EndsWith( + $"{Path.DirectorySeparatorChar}member", + workspace.GetProperty("members")[0].GetString(), + PathCasing.ComparisonFor(memberRoot)); + var hook = document.RootElement.GetProperty("hook"); + Assert.Equal("linked_worktree", hook.GetProperty("repository_type").GetString()); + Assert.Equal("shared_common_dir", hook.GetProperty("target_scope").GetString()); + Assert.EndsWith( + $"{Path.DirectorySeparatorChar}member", + hook.GetProperty("current_worktree").GetString(), + PathCasing.ComparisonFor(memberRoot)); + Assert.EndsWith( + Path.Combine("git-metadata", "hooks", "pre-commit"), + hook.GetProperty("hook_path").GetString(), + PathCasing.ComparisonFor(commonGitDir)); + + File.WriteAllText(Path.Combine(workspaceRoot, WorkspaceManifestLoader.FileName), "{"); + var (malformedExitCode, malformedStdout, malformedStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, malformedExitCode); + Assert.Empty(malformedStderr); + using var malformedDocument = JsonDocument.Parse(malformedStdout); + Assert.Equal( + "warning", + malformedDocument.RootElement + .GetProperty("project") + .GetProperty("workspace") + .GetProperty("status") + .GetString()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + TestProjectHelper.DeleteDirectory(workspaceRoot); + } + } + + [Fact] + public void RunDoctor_IntegrationsCheck_PreservesSparseCheckoutReadinessWithoutGitProcess_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor-integrations-sparse"); + var originalDirectory = Environment.CurrentDirectory; + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + var insidePath = Path.Combine(projectRoot, "src", "inside.cs"); + var outsidePath = Path.Combine(projectRoot, "src", "outside.cs"); + File.WriteAllText(insidePath, "class Inside {}\n"); + File.WriteAllText(outsidePath, "class Outside {}\n"); + TestProjectHelper.RunGit(projectRoot, "add", "src/inside.cs", "src/outside.cs"); + TestProjectHelper.RunGit(projectRoot, "commit", "-m", "initial"); + var indexedHead = TestProjectHelper.RunGit(projectRoot, "rev-parse", "HEAD").Trim(); + TestProjectHelper.RunGit(projectRoot, "update-index", "--skip-worktree", "src/outside.cs"); + File.Delete(outsidePath); + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/inside.cs", "csharp", "class Inside {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/outside.cs", "csharp", "class Outside {}\n"); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexCompletenessMetaKey, "complete"); + writer.SetMeta(DbContext.IndexIncompleteReasonsMetaKey, string.Empty); + writer.SetMeta(DbContext.WorkspaceVerifiedHeadShaMetaKey, indexedHead); + } + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + Environment.CurrentDirectory = projectRoot; + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var project = document.RootElement.GetProperty("project"); + Assert.Equal("ready", project.GetProperty("status").GetString()); + Assert.Equal("index_fresh", project.GetProperty("reason").GetString()); + Assert.True(project.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("matched", project.GetProperty("freshness_reason").GetString()); + + TestProjectHelper.RunGit(projectRoot, "commit", "--allow-empty", "-m", "head changed"); + var (changedExitCode, changedStdout, changedStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.StaleIndex, changedExitCode); + Assert.Empty(changedStderr); + using var changedDocument = JsonDocument.Parse(changedStdout); + var changedProject = changedDocument.RootElement.GetProperty("project"); + Assert.Equal("warning", changedProject.GetProperty("status").GetString()); + Assert.Equal("head_changed", changedProject.GetProperty("reason").GetString()); + Assert.False(changedProject.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("head_changed", changedProject.GetProperty("freshness_reason").GetString()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunDoctor_IntegrationsCheck_RebasesGitIndexAndHandlesSplitIndexWithoutGitProcess_Issue5102() + { + var repositoryRoot = TestProjectHelper.CreateTempProject("doctor-integrations-nested-git"); + var originalDirectory = Environment.CurrentDirectory; + try + { + TestProjectHelper.InitializeGitRepo(repositoryRoot); + var projectRoot = Path.Combine(repositoryRoot, "sub"); + Directory.CreateDirectory(projectRoot); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "sub/ignored.cs\n"); + File.WriteAllText(Path.Combine(projectRoot, "inside.cs"), "class Inside {}\n"); + File.WriteAllText(Path.Combine(projectRoot, "outside.cs"), "class Outside {}\n"); + File.WriteAllText(Path.Combine(projectRoot, "ignored.cs"), "class Ignored {}\n"); + TestProjectHelper.RunGit(repositoryRoot, "add", ".gitignore", "sub/inside.cs", "sub/outside.cs"); + TestProjectHelper.RunGit(repositoryRoot, "commit", "-m", "initial"); + var indexedHead = TestProjectHelper.RunGit(repositoryRoot, "rev-parse", "HEAD").Trim(); + TestProjectHelper.RunGit(repositoryRoot, "update-index", "--skip-worktree", "sub/outside.cs"); + File.Delete(Path.Combine(projectRoot, "outside.cs")); + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "inside.cs", "csharp", "class Inside {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "outside.cs", "csharp", "class Outside {}\n"); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexCompletenessMetaKey, "complete"); + writer.SetMeta(DbContext.IndexIncompleteReasonsMetaKey, string.Empty); + writer.SetMeta(DbContext.WorkspaceVerifiedHeadShaMetaKey, indexedHead); + } + + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + Environment.CurrentDirectory = projectRoot; + + var (readyExitCode, readyStdout, readyStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, readyExitCode); + Assert.Empty(readyStderr); + using (var readyDocument = JsonDocument.Parse(readyStdout)) + { + var project = readyDocument.RootElement.GetProperty("project"); + Assert.Equal("ready", project.GetProperty("status").GetString()); + Assert.True(project.GetProperty("index_matches_workspace").GetBoolean()); + } + + TestProjectHelper.RunGit(repositoryRoot, "update-index", "--split-index"); + var (splitExitCode, splitStdout, splitStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.StaleIndex, splitExitCode); + Assert.Empty(splitStderr); + using var splitDocument = JsonDocument.Parse(splitStdout); + var splitProject = splitDocument.RootElement.GetProperty("project"); + Assert.Equal("warning", splitProject.GetProperty("status").GetString()); + Assert.Equal("skip_worktree_metadata_unavailable", splitProject.GetProperty("reason").GetString()); + Assert.False(splitProject.TryGetProperty("index_matches_workspace", out _)); + Assert.Equal( + "skip_worktree_metadata_unavailable", + splitProject.GetProperty("freshness_reason").GetString()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + TestProjectHelper.DeleteDirectory(repositoryRoot); + } + } + + [Fact] + public void RunDoctor_IntegrationsCheck_RecognizesSparseIndexDirectoryPrefixesWithoutGitProcess_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor-integrations-sparse-index"); + var originalDirectory = Environment.CurrentDirectory; + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + Directory.CreateDirectory(Path.Combine(projectRoot, "docs")); + File.WriteAllText(Path.Combine(projectRoot, "src", "inside.cs"), "class Inside {}\n"); + File.WriteAllText(Path.Combine(projectRoot, "docs", "outside.cs"), "class Outside {}\n"); + TestProjectHelper.RunGit(projectRoot, "add", "src/inside.cs", "docs/outside.cs"); + TestProjectHelper.RunGit(projectRoot, "commit", "-m", "initial"); + var indexedHead = TestProjectHelper.RunGit(projectRoot, "rev-parse", "HEAD").Trim(); + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/inside.cs", "csharp", "class Inside {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/outside.cs", "csharp", "class Outside {}\n"); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexCompletenessMetaKey, "complete"); + writer.SetMeta(DbContext.IndexIncompleteReasonsMetaKey, string.Empty); + writer.SetMeta(DbContext.WorkspaceVerifiedHeadShaMetaKey, indexedHead); + } + + TestProjectHelper.RunGit(projectRoot, "sparse-checkout", "init", "--cone", "--sparse-index"); + TestProjectHelper.RunGit(projectRoot, "sparse-checkout", "set", "src"); + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + Environment.CurrentDirectory = projectRoot; + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--check", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var project = document.RootElement.GetProperty("project"); + Assert.Equal("ready", project.GetProperty("status").GetString()); + Assert.True(project.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("matched", project.GetProperty("freshness_reason").GetString()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunDoctor_IntegrationsJsonMaxBytes_AcceptsExactBudgetAndRejectsOverflow_Issue5102() + { + var projectRoot = TestProjectHelper.CreateTempProject("doctor-integrations-budget"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, Path.GetDirectoryName(dbPath)); + var (uncappedExitCode, uncappedStdout, uncappedStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json"], + appVersion: "1.10.0")); + var exactByteCount = Encoding.UTF8.GetByteCount(uncappedStdout); + + Assert.Equal(CommandExitCodes.Success, uncappedExitCode); + Assert.Empty(uncappedStderr); + + var (exactExitCode, exactStdout, exactStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json", $"--max-json-bytes={exactByteCount.ToString(CultureInfo.InvariantCulture)}"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, exactExitCode); + Assert.Empty(exactStderr); + Assert.Equal(uncappedStdout, exactStdout); + + var (overflowExitCode, overflowStdout, overflowStderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor", "--integrations", "--json", $"--max-json-bytes={(exactByteCount - 1).ToString(CultureInfo.InvariantCulture)}"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.UsageError, overflowExitCode); + Assert.Empty(overflowStderr); + using var errorDocument = JsonDocument.Parse(overflowStdout); + Assert.Contains("exceeds --max-json-bytes", errorDocument.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void EnvironmentVariableInventory_IncludesSecretAndPolicyClassifications() {