diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index c760081bcf..1a1af2b5f6 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -131,7 +131,7 @@ CI watching must be bounded. Do not loop indefinitely. ## Status Contract -- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, MCP-only `mcp_session`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. +- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. - When any readiness field is degraded, the CLI adds `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, and `readiness_degradations[]`. `degraded_root_cause` is the primary stable machine code; `readiness_degradations[]` lists every degraded field with `root_cause`, human reason, and remediation strings. - `hotspot_family_degraded_reason` currently uses `hotspot_family_support_not_indexed`, `hotspot_family_metadata_stale`, `hotspot_family_disabled_at_index_time`, `partial_family_key_population`, and `hotspot_family_marker_fingerprint_incomplete`; the incomplete marker fingerprint code means marker traversal hit safety caps and should stay synchronized with README / developer-guide recovery notes. - `issues_table_available` reports physical `file_issues` table presence only. `file_issues_data_current` reports whether the table is also stamped current for the active index generation. @@ -140,6 +140,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces unknown-extension scan coverage via `unknown_extension_file_count`, stamped by successful full-repository index runs (`cdidx index ` and MCP `index_project`) as the number of non-indexed files with non-empty extensions that do not map to a known language. Current scans also stamp `unknown_extension_files` as a path sample bounded by `unknown_extension_file_path_limit` items and the string-list decoded-character budget, `unknown_extension_files_truncated` when more paths existed than were emitted for either bound, and `unknown_extension_file_path_limit` as the item cap rather than a guarantee that that many paths are returned. Newer scans also expose `unknown_extension_extension_counts`, `unknown_extension_category_counts`, and `unknown_extension_groups`; groups classify common non-code buckets such as repository metadata, licenses, binary assets, configuration, structural metadata, and language-support candidates, and include `recommended_action` values of `ignore_configuration`, `first_class_structural_extraction`, or `language_support`. These fields are omitted on legacy DBs or before a current full scan has stamped them. - `status` also surfaces extractor plugin and pattern-config runtime diagnostics via `extractors`, including loaded counts, skipped file counts, and a bounded diagnostics list for incompatible or malformed plugin/pattern files. Diagnostic paths, categories, and messages are sanitized before output; `diagnostics[].category` is the stable machine-readable failure code. - `status` also surfaces metadata-only post-extraction hook candidates and callback budgets through `hooks[]` / `hooks[].callback_budget_ms` without loading hook assemblies. `hook_diagnostics[]` reports sanitized hook discovery and callback diagnostics such as candidate-limit truncation, assembly load failure, constructor failure, callback failure, and timeout. `hook_diagnostics[].category` is the stable machine-readable failure code. Index runs still enforce `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms) on scratch copies, discard timed-out mutations, and disable timed-out hooks for the remainder of the current run. +- `status` also surfaces accepted extension trust-boundary environment overrides through `trust_overrides[]`. Entries include `kind`, `environment_variable`, sanitized `value`, optional sanitized `path`, and `message`; current entries cover `CDIDX_TRUST_WORKSPACE_PLUGINS` workspace plugin discovery and `CDIDX_HOOKS_DIR` hook directory overrides. - `status` also surfaces `.cdidx` data-directory permissions via `data_dir_mode` on POSIX filesystems. New `.cdidx` data directories are forced to `0700`; the field is omitted on Windows, URI DBs, or when the directory mode cannot be inspected. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. If proc attribute reads fail on Linux, `mac_profile_diagnostics[]` reports bounded `path`, `category`, and `message` entries so users can distinguish "no profile" from "profile detection failed" (#1768, #3480). diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a1ac5bfbd8..d03115f17c 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1058,7 +1058,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | Readiness and graph trust | `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`. | | Workspace and HEAD freshness | `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`. | | Version and forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. | -| Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`. | +| Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | | MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. | @@ -1073,6 +1073,7 @@ Runtime diagnostic subcontracts: | `hotspot_family_marker_fingerprint_incomplete` | Marker fingerprint traversal hit safety caps during the last index run; narrow or ignore generated/vendor marker trees before rebuilding. | | `extractors` | Reports runtime extractor plugin and pattern-config health, including loaded plugin assembly and pattern counts, symbol/reference extractor counts, skipped file counts, and a bounded diagnostic list for incompatible or malformed files. Diagnostic paths and messages are sanitized before output. | | `hooks[]` / `hook_diagnostics[]` | Includes metadata-only hook candidates and `callback_budget_ms`, mirroring the post-extraction callback budget enforced by `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms). `hook_diagnostics[]` reports sanitized discovery diagnostics such as candidate-limit truncation and `CDIDX_HOOKS_DIR` override acceptance or rejection without loading hook assemblies. Index runs still load hooks and discard timed-out callback mutations because hooks run on a scratch copy before their results are applied. | +| `trust_overrides[]` | Reports accepted extension trust-boundary environment overrides, including `kind`, `environment_variable`, sanitized `value`, optional sanitized `path`, and `message`. Current entries cover accepted workspace plugin discovery via `CDIDX_TRUST_WORKSPACE_PLUGINS` and accepted hook directory overrides via `CDIDX_HOOKS_DIR`. | `references` already prefixes each human-readable row with `reference_kind`, and `callers` does the same for its grouped caller rows. When one grouped container mixes kinds (for example `call` and `subscribe` on the same event member), the human-readable label joins the distinct kinds with `+` (for example `call+subscribe`) instead of collapsing to a single preferred label, and the reference-kind column widens dynamically to fit the longest label in the batch so mixed rows do not overrun the neighbouring column. JSON output for `callers` and `callees` keeps the scalar `reference_kind` for back-compat (it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `reference_kinds` array plus a `has_mixed_reference_kinds` bool so consumers can detect mixed containers without trusting a single collapsed label. This lets terminal users distinguish `call` / `instantiate` / `subscribe` / mixed without re-running the command with `--json` and lets AI clients answer mixed-kind questions without chasing a second `--exact` query. @@ -3288,7 +3289,7 @@ AI エージェント向け検索ルールのテンプレートについては | readiness / graph trust | `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`。 | | workspace / HEAD freshness | `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`。 | | version / forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`。 | -| unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`。 | +| unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | | MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。 | @@ -3303,6 +3304,7 @@ runtime diagnostic subcontract: | `hotspot_family_marker_fingerprint_incomplete` | 前回 index run で marker fingerprint traversal が safety cap に当たったことを示します。rebuild 前に generated / vendor marker tree を narrow または ignore してください。 | | `extractors` | runtime extractor plugin と pattern-config の health を報告します。loaded plugin assembly / pattern count、symbol/reference extractor count、skipped file count、incompatible / malformed file 用の bounded diagnostic list を含みます。diagnostic の path と message は出力前に sanitization されます。 | | `hooks[]` / `hook_diagnostics[]` | metadata-only の hook candidate と `callback_budget_ms` を含みます。これは `CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定 5000 ms)が enforce する post-extraction callback budget と対応します。`hook_diagnostics[]` は candidate limit による打ち切りや `CDIDX_HOOKS_DIR` override の accepted / rejected など、sanitization 済みの discovery diagnostics を hook assembly を読み込まずに返します。index run は従来どおり hook を読み込んだうえで scratch copy 上の timeout した callback mutation を破棄します。 | +| `trust_overrides[]` | 受理された拡張信頼境界の環境変数 override を報告します。各 entry は `kind`、`environment_variable`、sanitization 済みの `value`、任意の sanitization 済み `path`、`message` を含みます。現在は `CDIDX_TRUST_WORKSPACE_PLUGINS` による workspace plugin discovery と `CDIDX_HOOKS_DIR` による hook directory override の受理を対象にします。 | `references` は以前から人間向け出力の各行先頭に `reference_kind` を表示しており、`callers` も grouped caller 行に対して同じタグを出す。1 つの grouped container で kind が混在する場合(例: 同じ event メンバに対する `call` と `subscribe`)は、単一 preferred label へ潰さずに `call+subscribe` のように distinct kind を `+` で連結して表示する。reference-kind 列の幅はバッチ内で最も長いラベルに合わせて動的に広がるため、mixed 行が隣接列を押し出さない。`callers` / `callees` の JSON 出力では、後方互換のため scalar な `reference_kind`(preferred 順 `instantiate` > `subscribe` > `MIN(call)` の要約 kind)を残しつつ、ソート済みの `reference_kinds` 配列と `has_mixed_reference_kinds` bool も追加した。これにより consumer は単一 summary label に騙されずに mixed container を検出できる。端末上でも `call` / `instantiate` / `subscribe` / mixed を `--json` なしで見分けられ、AI クライアントも `--exact` を改めて投げ直さずに mixed-kind の問いに答えられる。 diff --git a/README.md b/README.md index dde6050a36..9ecbfdb47e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ names visible so documentation and tests stay synchronized. | Readiness and graph trust | `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`. | | Workspace and HEAD freshness | `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`. | | Version and forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. | -| Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`. | +| Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | | MCP-only session diagnostics | `mcp_session`. | @@ -152,6 +152,8 @@ full-scan-only `indexed_head_commit` only for legacy DBs. Runtime diagnostics under `extractors.diagnostics[]` and `hook_diagnostics[]` include sanitized `category` machine codes alongside bounded paths and messages. +Accepted extension trust overrides such as `CDIDX_TRUST_WORKSPACE_PLUGINS` and +`CDIDX_HOOKS_DIR` are also reported in sanitized `trust_overrides[]` entries. Successful CLI and MCP index runs can also persist bounded `last_index_run.diagnostics` when best-effort metadata writes fail after the @@ -320,7 +322,7 @@ freshness、compatibility、remediation field を返します。詳細な意味 | readiness / graph trust | `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`。 | | workspace / HEAD freshness | `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`。 | | version / forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`。 | -| unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`。 | +| unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | | MCP-only session diagnostics | `mcp_session`。 | @@ -331,6 +333,8 @@ full-scan 限定 `indexed_head_commit` に fallback します。 `extractors.diagnostics[]` と `hook_diagnostics[]` の runtime diagnostics は、 bounded な path と message に加えて sanitization 済みの `category` machine code を含みます。 +受理された `CDIDX_TRUST_WORKSPACE_PLUGINS` や `CDIDX_HOOKS_DIR` などの +拡張信頼境界 override は、sanitization 済みの `trust_overrides[]` entry としても報告されます。 成功した CLI / MCP index run は、index data 自体の書き込みが成功した後に best-effort metadata write が失敗した場合、上限付きの diff --git a/changelog.d/unreleased/3698.fixed.md b/changelog.d/unreleased/3698.fixed.md new file mode 100644 index 0000000000..6e4215167b --- /dev/null +++ b/changelog.d/unreleased/3698.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3698 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtensionDiscoveryDiagnosticClassifier.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - tests/CodeIndex.Tests/ExtensionDiscoveryDiagnosticClassifierTests.cs +--- + +## English + +- **Extension discovery failures now share one taxonomy (#3698)** — plugin, pattern, and hook directory enumeration failures now classify path-too-long, missing directory, permission, invalid path, and fallback enumeration errors through the same bounded diagnostic helper. + +## 日本語 + +- **拡張 discovery 失敗の分類を共通化しました (#3698)** — plugin、pattern、hook の directory enumeration 失敗で、path-too-long、missing directory、permission、invalid path、fallback enumeration error を同じ上限付き diagnostic helper で分類するようになりました。 diff --git a/changelog.d/unreleased/3701.fixed.md b/changelog.d/unreleased/3701.fixed.md new file mode 100644 index 0000000000..1dc64c620d --- /dev/null +++ b/changelog.d/unreleased/3701.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3701 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtensionLoadDiagnosticClassifier.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - tests/CodeIndex.Tests/ExtensionLoadDiagnosticClassifierTests.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/PostExtractionHookTests.cs +--- + +## English + +- **Plugin and hook load diagnostics now use shared categories (#3701)** — assembly load, type-load, dependency-resolution, constructor, and activation failures now report bounded sanitized categories across extractor plugins and post-extraction hooks. + +## 日本語 + +- **plugin / hook の load diagnostic が共通カテゴリを使うようになりました (#3701)** — assembly load、type-load、dependency-resolution、constructor、activation の失敗を extractor plugin と post-extraction hook の両方で上限付き・sanitization 済みのカテゴリとして報告するようになりました。 diff --git a/changelog.d/unreleased/3703.internal.md b/changelog.d/unreleased/3703.internal.md new file mode 100644 index 0000000000..0077b84e92 --- /dev/null +++ b/changelog.d/unreleased/3703.internal.md @@ -0,0 +1,18 @@ +--- +category: internal +issues: + - 3703 +affected: + - src/CodeIndex/Indexer/IsolatedWorkerProcessLauncher.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Isolated worker process launch setup is now shared (#3703)** — symbol extraction and post-extraction hook workers now use one helper for process defaults, dotnet host fallback, runtime roll-forward, and protocol line-limit argument setup coverage. + +## 日本語 + +- **isolated worker の process launch 設定を共通化しました (#3703)** — symbol extraction worker と post-extraction hook worker が、process defaults、dotnet host fallback、runtime roll-forward、protocol line-limit 引数の検証を同じ helper で扱うようになりました。 diff --git a/changelog.d/unreleased/3735.added.md b/changelog.d/unreleased/3735.added.md new file mode 100644 index 0000000000..b8ced46180 --- /dev/null +++ b/changelog.d/unreleased/3735.added.md @@ -0,0 +1,28 @@ +--- +category: added +issues: + - 3735 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/DocumentationStatusContractTests.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/PostExtractionHookTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs + - README.md + - DEVELOPER_GUIDE.md + - AGENT_GUIDE.md +--- + +## English + +- **Status diagnostics now surface accepted extension trust overrides (#3735)** — CLI and MCP status output now include sanitized `trust_overrides[]` entries for accepted `CDIDX_TRUST_WORKSPACE_PLUGINS` and `CDIDX_HOOKS_DIR` overrides. + +## 日本語 + +- **status diagnostics が受理済みの拡張信頼 override を表示するようになりました (#3735)** — CLI と MCP の status 出力は、受理された `CDIDX_TRUST_WORKSPACE_PLUGINS` と `CDIDX_HOOKS_DIR` の override を sanitization 済みの `trust_overrides[]` entry として返すようになりました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index bc64bf8e46..3bbfa9a578 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -5906,6 +5906,11 @@ public static int RunStatus( var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHookSnapshot.Diagnostics.Count > 0) status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); + var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) + .Concat(postExtractionHookSnapshot.TrustOverrides) + .ToList(); + if (trustOverrides.Count > 0) + status.TrustOverrides = trustOverrides; if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks @@ -6030,6 +6035,16 @@ public static int RunStatus( } if (status.GraphSupportedLanguages is { Count: > 0 }) Console.WriteLine(ConsoleUi.FormatSummaryLine("Graph", $"{status.GraphSupportedLanguages.Count} languages ({string.Join(", ", status.GraphSupportedLanguages)})")); + if (status.TrustOverrides is { Count: > 0 }) + { + foreach (var trustOverride in status.TrustOverrides) + { + var pathSuffix = string.IsNullOrWhiteSpace(trustOverride.Path) + ? string.Empty + : $" ({trustOverride.Path})"; + Console.WriteLine(ConsoleUi.FormatSummaryLine("Trust", $"{trustOverride.Kind} via {trustOverride.EnvironmentVariable}{pathSuffix}")); + } + } // #1546: surface the persisted filesystem case-sensitivity so operators can // diagnose phantom path collapses on case-sensitive APFS / WSL / ReFS volumes. // #1546: case-sensitivity を診断用に明示する。 diff --git a/src/CodeIndex/Indexer/Extensibility/ExtensionDiscoveryDiagnosticClassifier.cs b/src/CodeIndex/Indexer/Extensibility/ExtensionDiscoveryDiagnosticClassifier.cs new file mode 100644 index 0000000000..16fa572d85 --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtensionDiscoveryDiagnosticClassifier.cs @@ -0,0 +1,40 @@ +namespace CodeIndex.Indexer.Extensibility; + +internal static class ExtensionDiscoveryDiagnosticClassifier +{ + internal static bool IsDiscoveryException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException; + + internal static ExtensionDiscoveryDiagnostic ClassifyDirectoryEnumerationFailure( + string categoryPrefix, + string surfaceLabel, + Exception ex) + { + var reason = ex switch + { + PathTooLongException => "path is too long", + DirectoryNotFoundException => "directory does not exist", + UnauthorizedAccessException => "permission denied", + ArgumentException or NotSupportedException => "path is invalid", + _ => "could not enumerate directory", + }; + var categorySuffix = ex switch + { + PathTooLongException => "path_too_long", + DirectoryNotFoundException => "directory_missing", + UnauthorizedAccessException => "permission_denied", + ArgumentException or NotSupportedException => "path_invalid", + _ => "enumeration_failed", + }; + var category = $"{categoryPrefix}_directory_{categorySuffix}"; + var exceptionCategory = SafeDiagnosticFormatter.FormatExceptionCategory(category, ex); + return new ExtensionDiscoveryDiagnostic( + category, + $"{reason} ({exceptionCategory})"); + } +} + +internal sealed record ExtensionDiscoveryDiagnostic(string Category, string Message); diff --git a/src/CodeIndex/Indexer/Extensibility/ExtensionLoadDiagnosticClassifier.cs b/src/CodeIndex/Indexer/Extensibility/ExtensionLoadDiagnosticClassifier.cs new file mode 100644 index 0000000000..56c396dae7 --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtensionLoadDiagnosticClassifier.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Indexer.Extensibility; + +internal static class ExtensionLoadDiagnosticClassifier +{ + private const int MaxLoaderExceptionDetails = 3; + + internal static ExtensionLoadDiagnostic ClassifyAssemblyLoad(string subject, Exception ex) + { + var category = IsDependencyResolutionException(ex) + ? "dependency_resolution_failed" + : "assembly_load_failed"; + return new ExtensionLoadDiagnostic( + category, + $"{subject} failed: {FormatException(category, ex)}."); + } + + internal static ExtensionLoadDiagnostic ClassifyTypeLoad(string subject, ReflectionTypeLoadException ex) + { + const string category = "type_load_failed"; + var loaderDetails = FormatLoaderExceptions(ex.LoaderExceptions); + var suffix = string.IsNullOrEmpty(loaderDetails) + ? string.Empty + : $" Loader exceptions: {loaderDetails}."; + return new ExtensionLoadDiagnostic( + category, + $"{subject} failed: {FormatException(category, ex)}.{suffix}"); + } + + internal static ExtensionLoadDiagnostic ClassifyConstructorFailure(string subject, Exception ex) + { + const string category = "constructor_failed"; + var failure = UnwrapTargetInvocation(ex); + return new ExtensionLoadDiagnostic( + category, + $"{subject} failed: {FormatException(category, failure)}."); + } + + private static string FormatLoaderExceptions(IEnumerable loaderExceptions) + { + var details = loaderExceptions + .Where(exception => exception != null) + .Select(exception => FormatException("loader_exception", exception!)) + .Distinct(StringComparer.Ordinal) + .Take(MaxLoaderExceptionDetails) + .ToList(); + + return string.Join(", ", details); + } + + private static string FormatException(string category, Exception ex) + { + var formatted = SafeDiagnosticFormatter.FormatExceptionCategory(category, ex); + var dependencyName = TryGetDependencyName(ex); + return dependencyName == null + ? formatted + : $"{formatted} ({dependencyName})"; + } + + private static string? TryGetDependencyName(Exception ex) + { + var fileName = ex switch + { + FileNotFoundException fileNotFound => fileNotFound.FileName, + FileLoadException fileLoad => fileLoad.FileName, + _ => null, + }; + if (string.IsNullOrWhiteSpace(fileName)) + return null; + + return "dependency " + DiagnosticSanitizer.ForMessage(fileName); + } + + private static bool IsDependencyResolutionException(Exception ex) + => ex is FileNotFoundException or FileLoadException; + + private static Exception UnwrapTargetInvocation(Exception ex) + => ex is TargetInvocationException { InnerException: not null } + ? ex.InnerException + : ex; +} + +internal sealed record ExtensionLoadDiagnostic(string Category, string Message); diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs index 10d5ceb17c..457df08c20 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs @@ -32,6 +32,9 @@ private static void ReportPatternConfigSkipped(string path, string reason) } private static void ReportPatternDirectoryRejected(string path, string reason) + => ReportPatternDirectoryRejected(path, reason, "pattern_directory_rejected"); + + private static void ReportPatternDirectoryRejected(string path, string reason, string category) { Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( @@ -41,7 +44,7 @@ private static void ReportPatternDirectoryRejected(string path, string reason) severity: "error", $"Pattern directory skipped: {reason}", countsAsSkippedFile: false, - category: "pattern_directory_rejected"); + category: category); } private static void ReportPatternDirectorySkipped(string path, string reason) @@ -124,3 +127,12 @@ public sealed record ExtractorRegistryDiagnostic( [property: JsonPropertyName("severity")] string Severity, [property: JsonPropertyName("category")] string Category, [property: JsonPropertyName("message")] string Message); + +public sealed record ExtensionTrustOverride( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("environment_variable")] string EnvironmentVariable, + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("path")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Path, + [property: JsonPropertyName("message")] string Message); diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs index 55fc123cf8..d9d3fd9785 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Discovery.cs @@ -1,3 +1,5 @@ +using CodeIndex.Diagnostics; + namespace CodeIndex.Indexer.Extensibility; public static partial class ExtractorPluginRegistry @@ -51,9 +53,13 @@ private static IEnumerable EnumeratePluginAssemblyPaths(IEnumerable pluginPath = enumerator.Current; return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) { - ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory", "plugin_directory_enumeration_failed"); + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + "plugin", + "Plugin directory", + ex); + ReportPluginDirectorySkipped(directory, diagnostic.Message, diagnostic.Category); return false; } } @@ -94,6 +104,33 @@ private static IEnumerable EnumerateWorkspacePluginDirectories(string pr yield return Path.Combine(projectRoot, ".cdidx", "plugins"); } + internal static IReadOnlyList GetAcceptedTrustOverrides(string? projectRoot) + { + var value = Environment.GetEnvironmentVariable(TrustWorkspacePluginsEnvironmentVariable); + if (!WorkspacePluginsTrusted(value) || string.IsNullOrWhiteSpace(projectRoot)) + return []; + + string fullRoot; + try + { + fullRoot = Path.GetFullPath(projectRoot); + } + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) + { + return []; + } + + return + [ + new ExtensionTrustOverride( + "workspace_plugin_directory", + TrustWorkspacePluginsEnvironmentVariable, + DiagnosticSanitizer.ForMessage(value), + DiagnosticSanitizer.ForPath(Path.Combine(fullRoot, ".cdidx", "plugins")), + "Workspace plugin discovery enabled by environment; workspace plugin DLLs execute checkout-provided code.") + ]; + } + private static IEnumerable EnumeratePatternConfigPaths(string workspaceRoot, bool includeUserDirectory = true) { foreach (var path in EnumeratePatternConfigPathsFromDirectory( @@ -158,9 +195,13 @@ private static IEnumerable EnumeratePatternConfigPathsFromDirectory(stri { return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly).GetEnumerator(); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) { - ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + "pattern", + "Pattern directory", + ex); + ReportPatternDirectoryRejected(directory, diagnostic.Message, diagnostic.Category); return null; } } @@ -176,9 +217,13 @@ private static bool TryMoveNextPatternFile(string directory, IEnumerator patternPath = enumerator.Current; return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) { - ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + "pattern", + "Pattern directory", + ex); + ReportPatternDirectoryRejected(directory, diagnostic.Message, diagnostic.Category); return false; } } @@ -220,8 +265,10 @@ private static bool DirectoryIsSymlinkOrReparsePoint(string directory) } private static bool WorkspacePluginsTrusted() + => WorkspacePluginsTrusted(Environment.GetEnvironmentVariable(TrustWorkspacePluginsEnvironmentVariable)); + + private static bool WorkspacePluginsTrusted(string? value) { - var value = Environment.GetEnvironmentVariable(TrustWorkspacePluginsEnvironmentVariable); return value != null && (value.Equals("1", StringComparison.OrdinalIgnoreCase) || value.Equals("true", StringComparison.OrdinalIgnoreCase) diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs index 091bf2bdff..12a9d8894d 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs @@ -59,6 +59,25 @@ private static void TryLoadPlugin(string pluginPath) return; } + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyTypeLoad("Plugin assembly type inspection", ex); + RecordDiagnostic( + "plugin", + fullPath, + typeName: null, + severity: "error", + diagnostic.Message, + countsAsSkippedFile: true, + category: diagnostic.Category); + return; + } + lock (Gate) { pluginAssemblyCount++; @@ -66,22 +85,23 @@ private static void TryLoadPlugin(string pluginPath) loadContext = null; } - foreach (var type in assembly.GetTypes()) + foreach (var type in types) { if (type is { IsAbstract: false, IsInterface: false } && type.GetConstructor(Type.EmptyTypes) != null) TryRegisterPluginType(type, fullPath); } } - catch (Exception) + catch (Exception ex) { + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyAssemblyLoad("Plugin assembly load", ex); RecordDiagnostic( "plugin", fullPath, typeName: null, severity: "error", - "Failed to load plugin assembly.", + diagnostic.Message, countsAsSkippedFile: true, - category: "assembly_load_failed"); + category: diagnostic.Category); } finally { @@ -178,16 +198,17 @@ private static void TryRegisterPluginType(Type type, string pluginPath) Register(referenceExtractor); } } - catch (Exception) + catch (Exception ex) { + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyConstructorFailure("Plugin type constructor", ex); RecordDiagnostic( "plugin_type", pluginPath, type.FullName, severity: "error", - "Failed to instantiate plugin type.", + diagnostic.Message, countsAsSkippedFile: false, - category: "plugin_type_instantiation_failed"); + category: diagnostic.Category); } } } diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs index 244ca957aa..265da612d5 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.Reflection; using System.Runtime.Loader; -using System.Runtime.Versioning; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -400,7 +399,7 @@ internal static bool TryCreateStartInfo( return TryCreateStartInfo( hook, Environment.ProcessPath, - ResolveCurrentRunnerAssemblyPath(), + IsolatedWorkerProcessLauncher.ResolveCurrentRunnerAssemblyPath(typeof(PostExtractionHookCallbackWorker).Assembly), maxProtocolLineBytes, out startInfo, out error); @@ -430,8 +429,11 @@ internal static bool TryCreateStartInfo( out ProcessStartInfo startInfo, out string error) { - startInfo = CreateStartInfo(); - if (ShouldStartCurrentExecutable(currentProcessPath, runnerAssemblyPath)) + startInfo = IsolatedWorkerProcessLauncher.CreateStartInfo(); + if (IsolatedWorkerProcessLauncher.ShouldStartCurrentExecutable( + currentProcessPath, + runnerAssemblyPath, + typeof(PostExtractionHookCallbackWorker).Assembly)) { startInfo.FileName = currentProcessPath!; startInfo.ArgumentList.Add(CommandName); @@ -442,28 +444,23 @@ internal static bool TryCreateStartInfo( return true; } - if (string.IsNullOrWhiteSpace(runnerAssemblyPath)) + if (!IsolatedWorkerProcessLauncher.TryPrepareFrameworkDependentStartInfo( + startInfo, + currentProcessPath, + runnerAssemblyPath, + typeof(PostExtractionHookCallbackWorker).Assembly, + "could not resolve the cdidx assembly path for isolated hook callback execution.", + "could not resolve a trusted dotnet host path for isolated hook callback execution; run cdidx through an absolute dotnet host path or use a self-contained cdidx executable.", + out error)) { startInfo = new ProcessStartInfo(); - error = "could not resolve the cdidx assembly path for isolated hook callback execution."; return false; } - var dotnetHostPath = DotnetHostPathResolver.Resolve(currentProcessPath); - if (dotnetHostPath == null) - { - startInfo = new ProcessStartInfo(); - error = "could not resolve a trusted dotnet host path for isolated hook callback execution; run cdidx through an absolute dotnet host path or use a self-contained cdidx executable."; - return false; - } - - startInfo.FileName = dotnetHostPath; - startInfo.ArgumentList.Add(runnerAssemblyPath); startInfo.ArgumentList.Add(CommandName); startInfo.ArgumentList.Add(hook.AssemblyPath); startInfo.ArgumentList.Add(hook.TypeName); AddProtocolLineLimitArguments(startInfo, maxProtocolLineBytes); - ApplyCurrentRuntimeRollForward(startInfo); error = string.Empty; return true; @@ -648,80 +645,6 @@ private static bool TryResolveProtocolLineLimit( return false; } - private static ProcessStartInfo CreateStartInfo() - => new() - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - CreateNoWindow = true, - }; - - private static bool ShouldStartCurrentExecutable(string? currentProcessPath, string? runnerAssemblyPath) - { - if (string.IsNullOrWhiteSpace(currentProcessPath) || DotnetHostPathResolver.IsDotnetHostPath(currentProcessPath)) - return false; - - var processName = Path.GetFileNameWithoutExtension(currentProcessPath); - var appName = typeof(PostExtractionHookCallbackWorker).Assembly.GetName().Name; - if (!string.IsNullOrWhiteSpace(appName) - && string.Equals(processName, appName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return string.IsNullOrWhiteSpace(runnerAssemblyPath); - } - - private static string? ResolveCurrentRunnerAssemblyPath() - { - var assemblyName = typeof(PostExtractionHookCallbackWorker).Assembly.GetName().Name; - if (string.IsNullOrWhiteSpace(assemblyName)) - return null; - - var candidate = Path.Combine(AppContext.BaseDirectory, assemblyName + ".dll"); - return File.Exists(candidate) ? candidate : null; - } - - private static void ApplyCurrentRuntimeRollForward(ProcessStartInfo startInfo) - { - var targetMajor = GetRunnerTargetFrameworkMajor(); - if (targetMajor.HasValue && Environment.Version.Major > targetMajor.Value) - startInfo.Environment["DOTNET_ROLL_FORWARD"] = "LatestMajor"; - } - - private static int? GetRunnerTargetFrameworkMajor() - { - var frameworkName = typeof(PostExtractionHookCallbackWorker) - .Assembly - .GetCustomAttribute() - ?.FrameworkName; - if (string.IsNullOrWhiteSpace(frameworkName)) - return null; - - const string versionPrefix = "Version=v"; - var versionIndex = frameworkName.IndexOf(versionPrefix, StringComparison.OrdinalIgnoreCase); - if (versionIndex < 0) - return null; - - var majorStart = versionIndex + versionPrefix.Length; - var majorEnd = frameworkName.IndexOf('.', majorStart); - var majorText = majorEnd < 0 - ? frameworkName[majorStart..] - : frameworkName[majorStart..majorEnd]; - return int.TryParse( - majorText, - System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, - out var major) - ? major - : null; - } - internal sealed record WorkerRequest( string Callback, FileContext Context, diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs index 844fbec300..f27e8d1874 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs @@ -32,7 +32,8 @@ public sealed record PostExtractionHookDiagnostic( public sealed record PostExtractionHookDiscoverySnapshot( IReadOnlyList Hooks, IReadOnlyList Diagnostics, - TimeSpan CallbackBudget); + TimeSpan CallbackBudget, + IReadOnlyList TrustOverrides); public sealed class PostExtractionHookRunner : IDisposable { @@ -68,21 +69,22 @@ public static PostExtractionHookRunner DiscoverDefault(long? maxFileSizeBytes = public static PostExtractionHookDiscoverySnapshot DiscoverDefaultMetadata() { var resolution = ResolveDefaultHooksDirectory(includeAcceptedOverrideDiagnostic: true); - return DiscoverMetadata(resolution.Directory, resolution.Diagnostics); + return DiscoverMetadata(resolution.Directory, resolution.Diagnostics, resolution.TrustOverrides); } public static PostExtractionHookDiscoverySnapshot DiscoverMetadata(string? hooksDirectory) - => DiscoverMetadata(hooksDirectory, []); + => DiscoverMetadata(hooksDirectory, [], []); private static PostExtractionHookDiscoverySnapshot DiscoverMetadata( string? hooksDirectory, - IReadOnlyList initialDiagnostics) + IReadOnlyList initialDiagnostics, + IReadOnlyList initialTrustOverrides) { var loaded = new List(); var runner = new PostExtractionHookRunner(loaded, ResolveCallbackBudget()); runner.EnqueueDiagnostics(initialDiagnostics); if (string.IsNullOrWhiteSpace(hooksDirectory) || !Directory.Exists(hooksDirectory)) - return new PostExtractionHookDiscoverySnapshot([], runner.Diagnostics, runner.CallbackBudget); + return new PostExtractionHookDiscoverySnapshot([], runner.Diagnostics, runner.CallbackBudget, initialTrustOverrides); var hooks = EnumerateHookAssemblyPaths(hooksDirectory, runner, ResolveDiscoveryLimit()) .Select(dllPath => @@ -95,7 +97,7 @@ private static PostExtractionHookDiscoverySnapshot DiscoverMetadata( }) .ToArray(); - return new PostExtractionHookDiscoverySnapshot(hooks, runner.Diagnostics, runner.CallbackBudget); + return new PostExtractionHookDiscoverySnapshot(hooks, runner.Diagnostics, runner.CallbackBudget, initialTrustOverrides); } public static PostExtractionHookRunner Discover(string? hooksDirectory, long? maxFileSizeBytes = null) @@ -128,9 +130,10 @@ private static PostExtractionHookRunner Discover( dllPath); assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); } - catch (Exception) + catch (Exception ex) { - runner.EnqueueDiagnostic(dllPath, null, "Failed to load hook assembly.", category: "assembly_load_failed"); + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyAssemblyLoad("Hook assembly load", ex); + runner.EnqueueDiagnostic(dllPath, null, diagnostic.Message, category: diagnostic.Category); continue; } @@ -139,9 +142,10 @@ private static PostExtractionHookRunner Discover( { types = assembly.GetTypes(); } - catch (ReflectionTypeLoadException) + catch (ReflectionTypeLoadException ex) { - runner.EnqueueDiagnostic(dllPath, null, "Failed to inspect hook assembly.", category: "assembly_inspection_failed"); + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyTypeLoad("Hook assembly type inspection", ex); + runner.EnqueueDiagnostic(dllPath, null, diagnostic.Message, category: diagnostic.Category); continue; } @@ -170,7 +174,7 @@ private static PostExtractionHookRunner Discover( } catch (Exception) { - runner.EnqueueDiagnostic(dllPath, type.FullName, "Failed to instantiate hook.", category: "hook_registration_failed"); + runner.EnqueueDiagnostic(dllPath, type.FullName, "Failed to instantiate hook.", category: "activation_failed"); } } } @@ -214,13 +218,17 @@ private static IReadOnlyList EnumerateHookAssemblyPaths( { return Directory.EnumerateFiles(hooksDirectory, "*.dll", SearchOption.TopDirectoryOnly).GetEnumerator(); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) { + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + "hook", + "Hook directory", + ex); runner.EnqueueDiagnostic( hooksDirectory, null, - "Failed to enumerate hook directory.", - category: "hook_directory_enumeration_failed"); + $"Hook directory skipped: {diagnostic.Message}.", + category: diagnostic.Category); return null; } } @@ -293,13 +301,17 @@ private static bool TryMoveNextHookFile( dllPath = enumerator.Current; return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ExtensionDiscoveryDiagnosticClassifier.IsDiscoveryException(ex)) { + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + "hook", + "Hook directory", + ex); runner.EnqueueDiagnostic( hooksDirectory, null, - "Failed to enumerate hook directory.", - category: "hook_directory_enumeration_failed"); + $"Hook directory skipped: {diagnostic.Message}.", + category: diagnostic.Category); return false; } } @@ -426,7 +438,18 @@ private static string ClassifyWorkerFailureCategory(string? workerError) return "callback_worker_failed"; if (workerError.StartsWith("worker_execution_failed:", StringComparison.Ordinal)) - return "hook_constructor_failed"; + { + if (workerError.Contains(nameof(FileNotFoundException), StringComparison.Ordinal) + || workerError.Contains(nameof(FileLoadException), StringComparison.Ordinal)) + { + return "dependency_resolution_failed"; + } + + if (workerError.Contains(nameof(TypeLoadException), StringComparison.Ordinal)) + return "type_load_failed"; + + return "constructor_failed"; + } if (workerError.StartsWith("worker_start_failed:", StringComparison.Ordinal)) return "worker_start_failed"; if (workerError.StartsWith("worker_protocol_error:", StringComparison.Ordinal)) @@ -561,6 +584,7 @@ private static HookDirectoryResolution ResolveDefaultHooksDirectory(bool include string.IsNullOrWhiteSpace(home) ? null : Path.Combine(home, ".config", "cdidx", "hooks"), + [], []); } @@ -569,6 +593,7 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( bool includeAcceptedOverrideDiagnostic) { var diagnostics = new List(); + var trustOverrides = new List(); string fullPath; try { @@ -581,7 +606,7 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( null, "Hook directory override rejected: path could not be resolved.", category: "hook_directory_override_invalid_path")); - return new HookDirectoryResolution(null, diagnostics); + return new HookDirectoryResolution(null, diagnostics, []); } try @@ -594,7 +619,7 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( null, "Hook directory override rejected: directory does not exist.", category: "hook_directory_override_missing")); - return new HookDirectoryResolution(null, diagnostics); + return new HookDirectoryResolution(null, diagnostics, []); } if ((directoryInfo.Attributes & FileAttributes.ReparsePoint) != 0 @@ -605,7 +630,7 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( null, "Hook directory override rejected: symbolic links and reparse points are not supported.", category: "hook_directory_override_rejected")); - return new HookDirectoryResolution(null, diagnostics); + return new HookDirectoryResolution(null, diagnostics, []); } } catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) @@ -615,7 +640,7 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( null, "Hook directory override rejected: directory could not be inspected.", category: "hook_directory_override_inspection_failed")); - return new HookDirectoryResolution(null, diagnostics); + return new HookDirectoryResolution(null, diagnostics, []); } AddUnixPermissionDiagnostic(fullPath, diagnostics); @@ -626,9 +651,15 @@ private static HookDirectoryResolution ResolveOverrideHooksDirectory( null, "Hook directory override accepted: hook assemblies execute local extension code from this trusted directory.", category: "hook_directory_override_accepted")); + trustOverrides.Add(new ExtensionTrustOverride( + "hook_directory_override", + HooksDirectoryEnvironmentVariable, + DiagnosticSanitizer.ForPath(overridePath), + DiagnosticSanitizer.ForPath(fullPath), + "Hook directory override accepted by environment; hook assemblies execute local extension code from this trusted directory.")); } - return new HookDirectoryResolution(fullPath, diagnostics); + return new HookDirectoryResolution(fullPath, diagnostics, trustOverrides); } private static void AddUnixPermissionDiagnostic(string fullPath, List diagnostics) @@ -704,5 +735,6 @@ private sealed record LoadedPostExtractionHook( private sealed record HookDirectoryResolution( string? Directory, - IReadOnlyList Diagnostics); + IReadOnlyList Diagnostics, + IReadOnlyList TrustOverrides); } diff --git a/src/CodeIndex/Indexer/IsolatedWorkerProcessLauncher.cs b/src/CodeIndex/Indexer/IsolatedWorkerProcessLauncher.cs new file mode 100644 index 0000000000..c8546e4142 --- /dev/null +++ b/src/CodeIndex/Indexer/IsolatedWorkerProcessLauncher.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Versioning; +using System.Text; + +namespace CodeIndex.Indexer; + +internal static class IsolatedWorkerProcessLauncher +{ + internal static ProcessStartInfo CreateStartInfo() + => new() + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + CreateNoWindow = true, + }; + + internal static bool ShouldStartCurrentExecutable( + string? currentProcessPath, + string? runnerAssemblyPath, + Assembly runnerAssembly) + { + if (string.IsNullOrWhiteSpace(currentProcessPath) || DotnetHostPathResolver.IsDotnetHostPath(currentProcessPath)) + return false; + + var processName = Path.GetFileNameWithoutExtension(currentProcessPath); + var appName = runnerAssembly.GetName().Name; + if (!string.IsNullOrWhiteSpace(appName) + && string.Equals(processName, appName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return string.IsNullOrWhiteSpace(runnerAssemblyPath); + } + + internal static string? ResolveCurrentRunnerAssemblyPath(Assembly runnerAssembly) + { + var assemblyName = runnerAssembly.GetName().Name; + if (string.IsNullOrWhiteSpace(assemblyName)) + return null; + + var candidate = Path.Combine(AppContext.BaseDirectory, assemblyName + ".dll"); + return File.Exists(candidate) ? candidate : null; + } + + internal static bool TryPrepareFrameworkDependentStartInfo( + ProcessStartInfo startInfo, + string? currentProcessPath, + string? runnerAssemblyPath, + Assembly runnerAssembly, + string missingAssemblyError, + string missingDotnetHostError, + out string error) + { + if (string.IsNullOrWhiteSpace(runnerAssemblyPath)) + { + error = missingAssemblyError; + return false; + } + + var dotnetHostPath = DotnetHostPathResolver.Resolve(currentProcessPath); + if (dotnetHostPath == null) + { + error = missingDotnetHostError; + return false; + } + + startInfo.FileName = dotnetHostPath; + startInfo.ArgumentList.Add(runnerAssemblyPath); + ApplyCurrentRuntimeRollForward(startInfo, runnerAssembly); + error = string.Empty; + return true; + } + + private static void ApplyCurrentRuntimeRollForward(ProcessStartInfo startInfo, Assembly runnerAssembly) + { + var targetMajor = GetRunnerTargetFrameworkMajor(runnerAssembly); + if (targetMajor.HasValue && Environment.Version.Major > targetMajor.Value) + startInfo.Environment["DOTNET_ROLL_FORWARD"] = "LatestMajor"; + } + + private static int? GetRunnerTargetFrameworkMajor(Assembly runnerAssembly) + { + var frameworkName = runnerAssembly + .GetCustomAttribute() + ?.FrameworkName; + if (string.IsNullOrWhiteSpace(frameworkName)) + return null; + + const string versionPrefix = "Version=v"; + var versionIndex = frameworkName.IndexOf(versionPrefix, StringComparison.OrdinalIgnoreCase); + if (versionIndex < 0) + return null; + + var majorStart = versionIndex + versionPrefix.Length; + var majorEnd = frameworkName.IndexOf('.', majorStart); + var majorText = majorEnd < 0 + ? frameworkName[majorStart..] + : frameworkName[majorStart..majorEnd]; + return int.TryParse( + majorText, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out var major) + ? major + : null; + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs index 7a32773094..d156a3f429 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Globalization; -using System.Reflection; -using System.Runtime.Versioning; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -392,7 +390,7 @@ internal static bool TryCreateStartInfo(int maxProtocolLineBytes, out ProcessSta { return TryCreateStartInfo( Environment.ProcessPath, - ResolveCurrentRunnerAssemblyPath(), + IsolatedWorkerProcessLauncher.ResolveCurrentRunnerAssemblyPath(typeof(SymbolExtractionWorker).Assembly), maxProtocolLineBytes, out startInfo, out error); @@ -419,8 +417,11 @@ internal static bool TryCreateStartInfo( out ProcessStartInfo startInfo, out string error) { - startInfo = CreateStartInfo(); - if (ShouldStartCurrentExecutable(currentProcessPath, runnerAssemblyPath)) + startInfo = IsolatedWorkerProcessLauncher.CreateStartInfo(); + if (IsolatedWorkerProcessLauncher.ShouldStartCurrentExecutable( + currentProcessPath, + runnerAssemblyPath, + typeof(SymbolExtractionWorker).Assembly)) { startInfo.FileName = currentProcessPath!; startInfo.ArgumentList.Add(CommandName); @@ -430,61 +431,27 @@ internal static bool TryCreateStartInfo( return true; } - if (string.IsNullOrWhiteSpace(runnerAssemblyPath)) + if (!IsolatedWorkerProcessLauncher.TryPrepareFrameworkDependentStartInfo( + startInfo, + currentProcessPath, + runnerAssemblyPath, + typeof(SymbolExtractionWorker).Assembly, + "could not resolve the cdidx assembly path for isolated symbol extraction.", + "could not resolve a trusted dotnet host path for isolated symbol extraction; run cdidx through an absolute dotnet host path or use a self-contained cdidx executable.", + out error)) { startInfo = new ProcessStartInfo(); - error = "could not resolve the cdidx assembly path for isolated symbol extraction."; return false; } - var dotnetHostPath = DotnetHostPathResolver.Resolve(currentProcessPath); - if (dotnetHostPath == null) - { - startInfo = new ProcessStartInfo(); - error = "could not resolve a trusted dotnet host path for isolated symbol extraction; run cdidx through an absolute dotnet host path or use a self-contained cdidx executable."; - return false; - } - - startInfo.FileName = dotnetHostPath; - startInfo.ArgumentList.Add(runnerAssemblyPath); startInfo.ArgumentList.Add(CommandName); AddProtocolLineLimitArguments(startInfo, maxProtocolLineBytes); AddTestingArguments(startInfo); - ApplyCurrentRuntimeRollForward(startInfo); error = string.Empty; return true; } - private static ProcessStartInfo CreateStartInfo() - => new() - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - CreateNoWindow = true, - }; - - private static bool ShouldStartCurrentExecutable(string? currentProcessPath, string? runnerAssemblyPath) - { - if (string.IsNullOrWhiteSpace(currentProcessPath) || DotnetHostPathResolver.IsDotnetHostPath(currentProcessPath)) - return false; - - var processName = Path.GetFileNameWithoutExtension(currentProcessPath); - var appName = typeof(SymbolExtractionWorker).Assembly.GetName().Name; - if (!string.IsNullOrWhiteSpace(appName) - && string.Equals(processName, appName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return string.IsNullOrWhiteSpace(runnerAssemblyPath); - } - internal static string? TryKillProcess(Process process) => WorkerProcessCleanupDiagnostics.TryKill(process, WorkerKillWaitMilliseconds); @@ -726,51 +693,6 @@ private static string BuildWorkerOptionError() + $"`{TestDelayMillisecondsOption} `, or " + $"`{TestConsoleStdoutOption} `."; - private static string? ResolveCurrentRunnerAssemblyPath() - { - var assemblyName = typeof(SymbolExtractionWorker).Assembly.GetName().Name; - if (string.IsNullOrWhiteSpace(assemblyName)) - return null; - - var candidate = Path.Combine(AppContext.BaseDirectory, assemblyName + ".dll"); - return File.Exists(candidate) ? candidate : null; - } - - private static void ApplyCurrentRuntimeRollForward(ProcessStartInfo startInfo) - { - var targetMajor = GetRunnerTargetFrameworkMajor(); - if (targetMajor.HasValue && Environment.Version.Major > targetMajor.Value) - startInfo.Environment["DOTNET_ROLL_FORWARD"] = "LatestMajor"; - } - - private static int? GetRunnerTargetFrameworkMajor() - { - var frameworkName = typeof(SymbolExtractionWorker) - .Assembly - .GetCustomAttribute() - ?.FrameworkName; - if (string.IsNullOrWhiteSpace(frameworkName)) - return null; - - const string versionPrefix = "Version=v"; - var versionIndex = frameworkName.IndexOf(versionPrefix, StringComparison.OrdinalIgnoreCase); - if (versionIndex < 0) - return null; - - var majorStart = versionIndex + versionPrefix.Length; - var majorEnd = frameworkName.IndexOf('.', majorStart); - var majorText = majorEnd < 0 - ? frameworkName[majorStart..] - : frameworkName[majorStart..majorEnd]; - return int.TryParse( - majorText, - System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, - out var major) - ? major - : null; - } - internal sealed record WorkerRequest( long FileId, string? Lang, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a15985fb29..475956774f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3106,6 +3106,11 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHookSnapshot.Diagnostics.Count > 0) status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); + var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) + .Concat(postExtractionHookSnapshot.TrustOverrides) + .ToList(); + if (trustOverrides.Count > 0) + status.TrustOverrides = trustOverrides; if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks @@ -3386,6 +3391,8 @@ private static JsonObject BuildMcpCompactStatusPayload(StatusResult status, IRea }; if (status.WorkspaceCheck is not null) payload["workspace_check"] = JsonSerializer.SerializeToNode(status.WorkspaceCheck); + if (status.TrustOverrides is { Count: > 0 }) + payload["trust_overrides"] = JsonSerializer.SerializeToNode(status.TrustOverrides); return payload; } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index ad80d1e801..53bd3c31dc 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -909,6 +909,9 @@ public class StatusResult [JsonPropertyName("hook_diagnostics")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? HookDiagnostics { get; set; } + [JsonPropertyName("trust_overrides")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? TrustOverrides { get; set; } [JsonPropertyName("extractors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExtractorRegistryStatus? Extractors { get; set; } diff --git a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs index 915b3795ad..550c3a30b6 100644 --- a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs +++ b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs @@ -32,6 +32,7 @@ public class DocumentationStatusContractTests "unknown_extension_category_counts", "unknown_extension_groups", "extractors", + "trust_overrides", "path_case_sensitive", "mac_profile", "stale_after_seconds", diff --git a/tests/CodeIndex.Tests/ExtensionDiscoveryDiagnosticClassifierTests.cs b/tests/CodeIndex.Tests/ExtensionDiscoveryDiagnosticClassifierTests.cs new file mode 100644 index 0000000000..2fffb30187 --- /dev/null +++ b/tests/CodeIndex.Tests/ExtensionDiscoveryDiagnosticClassifierTests.cs @@ -0,0 +1,32 @@ +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Tests; + +public class ExtensionDiscoveryDiagnosticClassifierTests +{ + [Theory] + [InlineData("plugin", typeof(PathTooLongException), "plugin_directory_path_too_long")] + [InlineData("pattern", typeof(DirectoryNotFoundException), "pattern_directory_directory_missing")] + [InlineData("hook", typeof(UnauthorizedAccessException), "hook_directory_permission_denied")] + [InlineData("plugin", typeof(ArgumentException), "plugin_directory_path_invalid")] + [InlineData("pattern", typeof(NotSupportedException), "pattern_directory_path_invalid")] + [InlineData("hook", typeof(IOException), "hook_directory_enumeration_failed")] + public void ClassifyDirectoryEnumerationFailure_UsesSharedBoundedTaxonomy( + string prefix, + Type exceptionType, + string expectedCategory) + { + var exception = (Exception)Activator.CreateInstance( + exceptionType, + new string('x', 1024))!; + + var diagnostic = ExtensionDiscoveryDiagnosticClassifier.ClassifyDirectoryEnumerationFailure( + prefix, + "Test directory", + exception); + + Assert.Equal(expectedCategory, diagnostic.Category); + Assert.Contains(exceptionType.Name, diagnostic.Message, StringComparison.Ordinal); + Assert.True(diagnostic.Message.Length < 240, diagnostic.Message); + } +} diff --git a/tests/CodeIndex.Tests/ExtensionLoadDiagnosticClassifierTests.cs b/tests/CodeIndex.Tests/ExtensionLoadDiagnosticClassifierTests.cs new file mode 100644 index 0000000000..06544e3948 --- /dev/null +++ b/tests/CodeIndex.Tests/ExtensionLoadDiagnosticClassifierTests.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Tests; + +public class ExtensionLoadDiagnosticClassifierTests +{ + [Fact] + public void ClassifyTypeLoad_BoundsLoaderExceptionDetails() + { + var pathLikeDependency = "/tmp/" + new string('x', 512) + "/Missing.Dependency.dll"; + var exception = new ReflectionTypeLoadException( + [], + [ + new FileNotFoundException("missing dependency", pathLikeDependency), + new InvalidOperationException(new string('y', 512)), + ]); + + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyTypeLoad( + "Plugin assembly type inspection", + exception); + + Assert.Equal("type_load_failed", diagnostic.Category); + Assert.Contains("Plugin assembly type inspection failed", diagnostic.Message, StringComparison.Ordinal); + Assert.Contains(nameof(ReflectionTypeLoadException), diagnostic.Message, StringComparison.Ordinal); + Assert.Contains(nameof(FileNotFoundException), diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain(pathLikeDependency, diagnostic.Message, StringComparison.Ordinal); + Assert.True(diagnostic.Message.Length < 320, diagnostic.Message); + } + + [Fact] + public void ClassifyConstructorFailure_UnwrapsTargetInvocationWithoutLeakingMessage() + { + var exception = new TargetInvocationException( + new InvalidOperationException("sensitive constructor message")); + + var diagnostic = ExtensionLoadDiagnosticClassifier.ClassifyConstructorFailure( + "Plugin type constructor", + exception); + + Assert.Equal("constructor_failed", diagnostic.Category); + Assert.Contains(nameof(InvalidOperationException), diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain("sensitive constructor message", diagnostic.Message, StringComparison.Ordinal); + } +} diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index 91f1f2db50..457819337d 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -9,6 +9,55 @@ namespace CodeIndex.Tests; public class ExtractorPluginRegistryTests { + internal const string ThrowingPluginConstructorEnvironmentVariable = "CDIDX_TEST_THROWING_PLUGIN_CTOR"; + + [Fact] + public void GetAcceptedTrustOverrides_ReportsWorkspacePluginTrust_3735() + { + var projectRoot = TestProjectHelper.CreateTempProject("extractor_registry_trust_override_3735"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); + try + { + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "yes"); + + var trustOverride = Assert.Single(ExtractorPluginRegistry.GetAcceptedTrustOverrides(projectRoot)); + + Assert.Equal("workspace_plugin_directory", trustOverride.Kind); + Assert.Equal(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, trustOverride.EnvironmentVariable); + Assert.Equal("yes", trustOverride.Value); + Assert.EndsWith(".cdidx/plugins", trustOverride.Path!, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, trustOverride.Path!, StringComparison.Ordinal); + Assert.Contains("workspace plugin", trustOverride.Message, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + + [Fact] + public void GetAcceptedTrustOverrides_IgnoresRejectedWorkspacePluginTrust_3735() + { + var projectRoot = TestProjectHelper.CreateTempProject("extractor_registry_trust_override_rejected_3735"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); + try + { + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "0"); + + Assert.Empty(ExtractorPluginRegistry.GetAcceptedTrustOverrides(projectRoot)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void EnumeratePluginAssemblyPaths_CapsCandidatesPerDirectory() { @@ -178,7 +227,8 @@ public void LoadPlugin_ReportsSanitizedAssemblyLoadCategory_3414() Assert.Equal("assembly_load_failed", diagnostic.Category); Assert.Equal("broken.dll", diagnostic.Path); Assert.DoesNotContain(projectRoot, diagnostic.Path, StringComparison.Ordinal); - Assert.Equal("Failed to load plugin assembly.", diagnostic.Message); + Assert.Contains("Plugin assembly load failed", diagnostic.Message, StringComparison.Ordinal); + Assert.Contains(nameof(BadImageFormatException), diagnostic.Message, StringComparison.Ordinal); } finally { @@ -188,6 +238,35 @@ public void LoadPlugin_ReportsSanitizedAssemblyLoadCategory_3414() } } + [Fact] + public void LoadPlugin_ReportsSanitizedConstructorFailure_3701() + { + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(ThrowingPluginConstructorEnvironmentVariable); + try + { + ExtractorPluginRegistry.ResetForTests(); + env.Set(ThrowingPluginConstructorEnvironmentVariable, "1"); + + ExtractorPluginRegistry.LoadPluginForTests(Assembly.GetExecutingAssembly().Location); + + var diagnostic = Assert.Single( + ExtractorPluginRegistry.GetStatusSnapshot().Diagnostics!, + item => item.TypeName == typeof(ThrowingPluginSymbolExtractor).FullName); + Assert.Equal("plugin_type", diagnostic.Kind); + Assert.Equal("error", diagnostic.Severity); + Assert.Equal("constructor_failed", diagnostic.Category); + Assert.Contains(nameof(InvalidOperationException), diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain("plugin ctor boom", diagnostic.Message, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + [Fact] public void LoadPlugin_LoadsExtractorAssemblyInCollectibleContext_3413() { @@ -418,3 +497,19 @@ public sealed class CollectiblePluginSymbolExtractor : ISymbolExtractor public IReadOnlyList Extract(long fileId, string source, ExtractionContext context) => []; } + +public sealed class ThrowingPluginSymbolExtractor : ISymbolExtractor +{ + public ThrowingPluginSymbolExtractor() + { + if (Environment.GetEnvironmentVariable(ExtractorPluginRegistryTests.ThrowingPluginConstructorEnvironmentVariable) == "1") + throw new InvalidOperationException("plugin ctor boom"); + } + + public string Language => "throwingplugindsl"; + + public IReadOnlyCollection FileExtensions => [".throwingplugin"]; + + public IReadOnlyList Extract(long fileId, string source, ExtractionContext context) + => []; +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index da942cbf68..620df80021 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Runtime.Versioning; @@ -697,6 +698,66 @@ public void SymbolExtractionWorker_StartInfo_RaisesProtocolLimitForLargeFileCap_ startInfo.ArgumentList); } + [Fact] + public void IsolatedWorkers_StartInfo_ShareDefaultsAndProtocolArguments_Issue3703() + { + var protocolLimit = WorkerProtocolLineLimits.MaxLineUtf8Bytes + 1024; + var currentProcessPath = Path.Combine(Path.GetTempPath(), OperatingSystem.IsWindows() ? "cdidx.exe" : "cdidx"); + var hook = new PostExtractionHookInfo( + "demo", + Path.Combine(Path.GetTempPath(), "demo-hook.dll"), + "Demo.Hook"); + + var symbolCreated = SymbolExtractionWorker.TryCreateStartInfo( + currentProcessPath, + runnerAssemblyPath: string.Empty, + protocolLimit, + out var symbolStartInfo, + out var symbolError); + var hookCreated = PostExtractionHookCallbackWorker.TryCreateStartInfo( + hook, + currentProcessPath, + runnerAssemblyPath: string.Empty, + protocolLimit, + out var hookStartInfo, + out var hookError); + + Assert.True(symbolCreated, symbolError); + Assert.True(hookCreated, hookError); + AssertIsolatedWorkerStartInfoDefaults(symbolStartInfo); + AssertIsolatedWorkerStartInfoDefaults(hookStartInfo); + Assert.Equal(currentProcessPath, symbolStartInfo.FileName); + Assert.Equal(currentProcessPath, hookStartInfo.FileName); + Assert.Equal( + [ + SymbolExtractionWorker.CommandName, + "--protocol-max-line-bytes", + protocolLimit.ToString(CultureInfo.InvariantCulture), + ], + symbolStartInfo.ArgumentList); + Assert.Equal( + [ + PostExtractionHookCallbackWorker.CommandName, + hook.AssemblyPath, + hook.TypeName, + "--protocol-max-line-bytes", + protocolLimit.ToString(CultureInfo.InvariantCulture), + ], + hookStartInfo.ArgumentList); + } + + private static void AssertIsolatedWorkerStartInfoDefaults(ProcessStartInfo startInfo) + { + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.RedirectStandardInput); + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.True(startInfo.CreateNoWindow); + Assert.Equal(Encoding.UTF8.WebName, startInfo.StandardInputEncoding?.WebName); + Assert.Equal(Encoding.UTF8.WebName, startInfo.StandardOutputEncoding?.WebName); + Assert.Equal(Encoding.UTF8.WebName, startInfo.StandardErrorEncoding?.WebName); + } + [Fact] public void PostExtractionHookCallbackWorker_StartInfo_UsesCurrentCdidxExecutableWhenAvailable() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e502526a15..3f48cfb06b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -10,6 +10,8 @@ using CodeIndex.Database; using CodeIndex.Diagnostics; using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; using CodeIndex.Mcp; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -7922,6 +7924,38 @@ public void ToolsCall_Status_ReportsPaginationOffsetCap() Assert.Equal(McpServer.MaxMcpPaginationOffset, limits["max_pagination_offset"]!.GetValue()); } + [Fact] + public void ToolsCall_StatusCompact_ReportsAcceptedExtensionTrustOverrides_3735() + { + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, + ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); + var hooksDir = Path.Combine(_projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "on"); + + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact"}}}""")!)!; + + var trustOverrides = response["result"]!["structuredContent"]!["trust_overrides"]!.AsArray(); + Assert.Equal(2, trustOverrides.Count); + Assert.Contains( + trustOverrides, + item => item?["kind"]?.GetValue() == "workspace_plugin_directory" + && item["environment_variable"]!.GetValue() == ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable + && item["value"]!.GetValue() == "on"); + var hookOverride = Assert.Single( + trustOverrides, + item => item?["kind"]?.GetValue() == "hook_directory_override"); + Assert.Equal(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hookOverride!["environment_variable"]!.GetValue()); + Assert.EndsWith("hooks", hookOverride["path"]!.GetValue(), StringComparison.Ordinal); + Assert.DoesNotContain(_projectRoot, hookOverride["path"]!.GetValue(), StringComparison.Ordinal); + } + } + [Fact] public void Constructor_InvalidKeepAliveEnvironment_DoesNotThrow() { diff --git a/tests/CodeIndex.Tests/PostExtractionHookTests.cs b/tests/CodeIndex.Tests/PostExtractionHookTests.cs index 4d32c747e1..13a8397216 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookTests.cs @@ -163,7 +163,7 @@ public void WorkerConstructionFailure_DisablesHookForCurrentRun() var diagnostic = Assert.Single( runner.Diagnostics, diagnostic => diagnostic.TypeName == typeof(ThrowingConstructorPostExtractionHook).FullName); - Assert.Equal("hook_constructor_failed", diagnostic.Category); + Assert.Equal("constructor_failed", diagnostic.Category); Assert.Contains("isolated worker", diagnostic.Message, StringComparison.Ordinal); Assert.DoesNotContain("ctor boom", diagnostic.Message, StringComparison.Ordinal); } @@ -361,6 +361,14 @@ public void DiscoverDefaultMetadata_ReportsAcceptedHooksDirectoryOverride_3415() diagnostic => diagnostic.AssemblyPath.EndsWith("hooks", StringComparison.Ordinal) && diagnostic.Category == "hook_directory_override_accepted" && diagnostic.Message.Contains("override accepted", StringComparison.Ordinal)); + var trustOverride = Assert.Single(snapshot.TrustOverrides); + Assert.Equal("hook_directory_override", trustOverride.Kind); + Assert.Equal(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, trustOverride.EnvironmentVariable); + Assert.EndsWith("hooks", trustOverride.Value, StringComparison.Ordinal); + Assert.EndsWith("hooks", trustOverride.Path!, StringComparison.Ordinal); + Assert.Contains("hook assemblies execute", trustOverride.Message, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, trustOverride.Value, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, trustOverride.Path!, StringComparison.Ordinal); Assert.All( snapshot.Diagnostics, diagnostic => Assert.DoesNotContain(projectRoot, diagnostic.AssemblyPath, StringComparison.Ordinal)); @@ -429,8 +437,8 @@ public void Discover_CapsHookAssemblyCandidates() && diagnostic.Message.Contains("candidate limit", StringComparison.Ordinal)); Assert.Equal( 2, - runner.Diagnostics.Count(diagnostic => diagnostic.Category == "assembly_load_failed" - && diagnostic.Message.StartsWith("Failed to load hook assembly", StringComparison.Ordinal))); + runner.Diagnostics.Count(diagnostic => diagnostic.Category is "assembly_load_failed" or "dependency_resolution_failed" + && diagnostic.Message.StartsWith("Hook assembly load failed", StringComparison.Ordinal))); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index 73c5280dc7..5810b76e91 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Indexer.Extensibility; using CodeIndex.Indexer.Hooks; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -220,6 +221,59 @@ public void RunStatus_Json_ReportsHookDiscoveryLimitDiagnostics_3456() } } + [Fact] + public void RunStatus_Json_ReportsAcceptedExtensionTrustOverrides_3735() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_trust_overrides_3735"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, + ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var hooksDir = Path.Combine(projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); + env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "true"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", dbPath, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var trustOverrides = document.RootElement.GetProperty("trust_overrides").EnumerateArray().ToArray(); + Assert.Equal(2, trustOverrides.Length); + + var pluginOverride = Assert.Single( + trustOverrides, + item => item.GetProperty("kind").GetString() == "workspace_plugin_directory"); + Assert.Equal(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, pluginOverride.GetProperty("environment_variable").GetString()); + Assert.Equal("true", pluginOverride.GetProperty("value").GetString()); + Assert.EndsWith(".cdidx/plugins", pluginOverride.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, pluginOverride.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.Contains("workspace plugin", pluginOverride.GetProperty("message").GetString(), StringComparison.Ordinal); + + var hookOverride = Assert.Single( + trustOverrides, + item => item.GetProperty("kind").GetString() == "hook_directory_override"); + Assert.Equal(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hookOverride.GetProperty("environment_variable").GetString()); + Assert.EndsWith("hooks", hookOverride.GetProperty("value").GetString(), StringComparison.Ordinal); + Assert.EndsWith("hooks", hookOverride.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, hookOverride.GetProperty("value").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, hookOverride.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.Contains("hook assemblies execute", hookOverride.GetProperty("message").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void RunStatus_Json_CapsSymbolKindCountsAndNames_3134() {