diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index ad7ea033a2..9022f8570a 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`, `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`, 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`, `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`. - 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. @@ -139,7 +139,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces indexed-HEAD freshness via `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, and `commits_ahead_of_indexed_head`. They are stamped by `cdidx index` on every successful run (full scan AND partial update, distinct from `indexed_head_commit` which is full-scan only) on a best-effort basis (never blocks an otherwise-successful index) and omitted on non-git workspaces, detached HEAD (branch only), or legacy DBs created before this contract. - `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 and messages are sanitized before output. -- `status` also surfaces metadata-only post-extraction hook candidates and callback budgets through `hooks[]` / `hooks[].callback_budget_ms` without loading hook assemblies. 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 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 diagnostics such as candidate-limit truncation. 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 `.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. It is omitted on non-Linux hosts, unconstrained processes, or unreadable proc attributes (#1768). diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9bd5dc2bdf..aef6e23499 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -202,6 +202,10 @@ trust metadata before the dependent rows are written. Out-of-tree post-extraction hooks can implement `CodeIndex.Indexer.Hooks.IPostExtractionHook` in a `.dll` placed under `~/.config/cdidx/hooks/` (or the directory named by `CDIDX_HOOKS_DIR`). Hook discovery examines at most `CDIDX_HOOK_DISCOVERY_MAX_DLLS` DLL candidates (default: 128), requires each candidate to be no larger than `CDIDX_HOOK_DISCOVERY_MAX_BYTES` bytes (default: 67108864), then loads the bounded candidate set in path order. Each concrete hook type is instantiated with a public parameterless constructor, then called after built-in symbol extraction and again after built-in reference extraction, before rows are persisted. Hooks receive a `FileContext` plus mutable `IList` / `IList` values, so they can annotate extracted records, add synthetic symbols, or add domain-specific references. +Extractor plugin assemblies and hook assemblies load through custom collectible assembly load contexts instead of `AssemblyLoadContext.Default`. Dependency resolution first reuses already-loaded default assemblies such as CodeIndex's shared interfaces, then resolves private dependencies relative to the extension assembly path. + +`CDIDX_HOOKS_DIR` is a trust boundary override. Point it only at a local directory controlled by trusted users because hook assemblies execute extension code. `status --json` and MCP `status` report sanitized `hook_diagnostics[]` when the override is accepted or rejected, reject missing or symlink/reparse-point override directories, and warn when Unix permissions make the directory group- or world-writable. + Hook failures are isolated to that hook invocation: assembly load, construction, and callback exceptions are captured as diagnostics and indexing continues. Each loaded hook runs in an isolated worker process, and callbacks run against scratch copies with a bounded wall-clock budget controlled by `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms). The first callback budget covers worker startup and callback execution. A timed-out callback kills the worker process tree, contributes no mutations, emits an index warning, and disables that hook for the remainder of the current index run. `status --json` and MCP `status` expose loaded hooks under `hooks` with `name`, `assembly_path`, `type_name`, and `callback_budget_ms` so users can confirm which extensions are active and what timeout is being enforced. ### Ignore file parsing @@ -1035,7 +1039,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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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. | @@ -1049,7 +1053,7 @@ Runtime diagnostic subcontracts: | `partial_family_key_population` | Some indexed symbols still lack family keys and need a rebuild/restamp. | | `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[]` | 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). `status` does not load 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. | +| `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. | `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. @@ -2405,6 +2409,10 @@ built-in symbol extraction 後と built-in reference extraction 後、row 永続 hook は `FileContext` と mutable な `IList` / `IList` を受け取り、 extracted record の annotation、synthetic symbol 追加、domain-specific reference 追加ができます。 +Extractor plugin assembly と hook assembly は `AssemblyLoadContext.Default` ではなく、custom collectible assembly load context で読み込まれます。dependency resolution はまず CodeIndex の共有 interface など既に Default ALC に読み込まれている assembly を再利用し、その後 extension assembly path から private dependency を解決します。 + +`CDIDX_HOOKS_DIR` は trust boundary の override です。hook assembly は extension code を実行するため、信頼できる user が管理する local directory だけを指定してください。`status --json` と MCP `status` は override が accepted / rejected になった場合に sanitization 済みの `hook_diagnostics[]` を返し、存在しない directory や symlink / reparse point の override directory を拒否します。Unix permissions が group- または world-writable な directory も warning として報告します。 + assembly load、construction、callback exception は diagnostic として捕捉され、indexing は継続します。 各 loaded hook は isolated worker process 内で動き、callback は scratch copy 上で実行され、 `CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定 5000 ms)の wall-clock budget が適用されます。 @@ -3242,7 +3250,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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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 を示します。 | @@ -3256,7 +3264,7 @@ runtime diagnostic subcontract: | `partial_family_key_population` | 一部の indexed symbol に family key がまだ無く、rebuild / restamp が必要です。 | | `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[]` | metadata-only の hook candidate と `callback_budget_ms` を含みます。これは `CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定 5000 ms)が enforce する post-extraction callback budget と対応します。`status` は hook assembly を読み込まず、index run は従来どおり hook を読み込んだうえで scratch copy 上の timeout した callback mutation を破棄します。 | +| `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 を破棄します。 | `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 5081ef5822..d1c8d696b7 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`. | @@ -289,7 +289,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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `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`。 | diff --git a/changelog.d/unreleased/3413.security.md b/changelog.d/unreleased/3413.security.md new file mode 100644 index 0000000000..e820c5c227 --- /dev/null +++ b/changelog.d/unreleased/3413.security.md @@ -0,0 +1,21 @@ +--- +category: security +issues: + - 3413 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtensionAssemblyLoadContext.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/PostExtractionHookTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Extension assemblies no longer load through the default assembly load context (#3413)** — extractor plugins and post-extraction hook callbacks now use custom collectible load contexts with explicit dependency resolution, keeping CodeIndex shared interfaces unified while avoiding default-context pinning for extension code. + +## 日本語 + +- **extension assembly が default assembly load context で読み込まれないようになりました (#3413)** — extractor plugin と post-extraction hook callback は明示的な dependency resolution を持つ custom collectible load context を使い、CodeIndex の共有 interface の型同一性を保ちながら extension code が default context に固定されないようにしました。 diff --git a/changelog.d/unreleased/3415.security.md b/changelog.d/unreleased/3415.security.md new file mode 100644 index 0000000000..962d914ea7 --- /dev/null +++ b/changelog.d/unreleased/3415.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 3415 +affected: + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - tests/CodeIndex.Tests/PostExtractionHookTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`CDIDX_HOOKS_DIR` now reports trust-boundary diagnostics (#3415)** — hook directory overrides now reject missing or symlink/reparse-point directories, warn on group- or world-writable Unix directories, and report sanitized accepted/rejected diagnostics through `status --json` and MCP status. + +## 日本語 + +- **`CDIDX_HOOKS_DIR` が trust boundary diagnostics を返すようになりました (#3415)** — hook directory override は存在しない directory や symlink / reparse point directory を拒否し、Unix で group- または world-writable な directory を warning として報告し、`status --json` と MCP status に sanitization 済みの accepted / rejected diagnostics を返します。 diff --git a/changelog.d/unreleased/3456.fixed.md b/changelog.d/unreleased/3456.fixed.md new file mode 100644 index 0000000000..424dbef61c --- /dev/null +++ b/changelog.d/unreleased/3456.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 3456 +affected: + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs + - README.md + - DEVELOPER_GUIDE.md + - AGENT_GUIDE.md +--- + +## English + +- **Hook discovery truncation diagnostics are now visible in status output (#3456)** — `status --json` and MCP status now include sanitized `hook_diagnostics[]` entries when hook discovery hits candidate limits, so oversized extension directories report bounded truncation without loading hook assemblies. + +## 日本語 + +- **hook discovery の打ち切り diagnostics が status 出力で見えるようになりました (#3456)** — `status --json` と MCP status は hook discovery が candidate limit に達した場合に sanitization 済みの `hook_diagnostics[]` を返し、hook assembly を読み込まずに大きすぎる extension directory の bounded truncation を報告します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 604e26fde7..e4b0b6b767 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -5140,6 +5140,8 @@ public static int RunStatus( status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; + if (postExtractionHookSnapshot.Diagnostics.Count > 0) + status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks diff --git a/src/CodeIndex/Indexer/Extensibility/ExtensionAssemblyLoadContext.cs b/src/CodeIndex/Indexer/Extensibility/ExtensionAssemblyLoadContext.cs new file mode 100644 index 0000000000..da85e797cc --- /dev/null +++ b/src/CodeIndex/Indexer/Extensibility/ExtensionAssemblyLoadContext.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace CodeIndex.Indexer.Extensibility; + +internal sealed class ExtensionAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver resolver; + + internal ExtensionAssemblyLoadContext(string name, string mainAssemblyPath) + : base(name, isCollectible: true) + { + resolver = new AssemblyDependencyResolver(Path.GetFullPath(mainAssemblyPath)); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var sharedAssembly = ResolveDefaultAssembly(assemblyName); + if (sharedAssembly != null) + return sharedAssembly; + + var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath == null ? null : LoadFromAssemblyPath(assemblyPath); + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return libraryPath == null ? IntPtr.Zero : LoadUnmanagedDllFromPath(libraryPath); + } + + private static Assembly? ResolveDefaultAssembly(AssemblyName assemblyName) + { + foreach (var assembly in Default.Assemblies) + { + AssemblyName defaultName; + try + { + defaultName = assembly.GetName(); + } + catch (NotSupportedException) + { + continue; + } + + if (AssemblyName.ReferenceMatchesDefinition(defaultName, assemblyName)) + return assembly; + } + + return null; + } +} diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 91ef507ba5..f24ea7d147 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -31,6 +31,7 @@ public static class ExtractorPluginRegistry private static readonly Dictionary ReferenceExtractors = new(StringComparer.Ordinal); private static readonly HashSet LoadedPluginAssemblyPaths = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet LoadedPatternConfigPaths = new(StringComparer.OrdinalIgnoreCase); + private static readonly List LoadedPluginAssemblyContexts = []; private static readonly IReadOnlyList PatternConfigSearchPatterns = ["*.yaml", "*.yml"]; private static readonly List Diagnostics = []; private const int DiagnosticLimit = 20; @@ -134,6 +135,7 @@ internal static void ResetForTests() ReferenceExtractors.Clear(); LoadedPluginAssemblyPaths.Clear(); LoadedPatternConfigPaths.Clear(); + UnloadPluginAssemblyContexts(); Diagnostics.Clear(); pluginAssemblyCount = 0; patternConfigCount = 0; @@ -152,6 +154,7 @@ internal static void ReloadForTests() ReferenceExtractors.Clear(); LoadedPluginAssemblyPaths.Clear(); LoadedPatternConfigPaths.Clear(); + UnloadPluginAssemblyContexts(); Diagnostics.Clear(); pluginAssemblyCount = 0; patternConfigCount = 0; @@ -174,6 +177,12 @@ internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests(IRead internal static IReadOnlyList EnumeratePatternConfigPathsFromDirectoryForTests(string directory) => EnumeratePatternConfigPathsFromDirectory(directory, workspaceRoot: null).ToArray(); + internal static IReadOnlyList PluginAssemblyLoadContextsForTests() + { + lock (Gate) + return LoadedPluginAssemblyContexts.ToList(); + } + internal static void LoadPluginAssembliesForTests(IReadOnlyList directories) => LoadPluginAssemblies(directories); @@ -996,6 +1005,7 @@ private static ReadOnlySpan TrimScalarQuotes(ReadOnlySpan value) private static void TryLoadPlugin(string pluginPath) { var fullPath = pluginPath; + ExtensionAssemblyLoadContext? loadContext = null; try { fullPath = Path.GetFullPath(pluginPath); @@ -1008,7 +1018,10 @@ private static void TryLoadPlugin(string pluginPath) if (!PluginAssemblyCandidateIsWithinBudget(fullPath)) return; - var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + loadContext = new ExtensionAssemblyLoadContext( + $"cdidx-plugin:{Path.GetFileNameWithoutExtension(fullPath)}", + fullPath); + var assembly = loadContext.LoadFromAssemblyPath(fullPath); var attribute = assembly.GetCustomAttribute(); if (attribute == null) { @@ -1036,7 +1049,11 @@ private static void TryLoadPlugin(string pluginPath) } lock (Gate) + { pluginAssemblyCount++; + LoadedPluginAssemblyContexts.Add(loadContext); + loadContext = null; + } foreach (var type in assembly.GetTypes()) { @@ -1054,6 +1071,21 @@ private static void TryLoadPlugin(string pluginPath) "Failed to load plugin assembly.", countsAsSkippedFile: true); } + finally + { + loadContext?.Unload(); + } + } + + private static void UnloadPluginAssemblyContexts() + { + foreach (var loadContext in LoadedPluginAssemblyContexts) + { + if (loadContext.IsCollectible) + loadContext.Unload(); + } + + LoadedPluginAssemblyContexts.Clear(); } private static bool PluginAssemblyCandidateIsWithinBudget(string fullPath) diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs index 780958de24..7995b81591 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using CodeIndex.Indexer.Extensibility; using CodeIndex.Models; namespace CodeIndex.Indexer.Hooks; @@ -553,7 +554,11 @@ private static int RunCommand( private static IPostExtractionHook CreateHook(string hookAssemblyPath, string hookTypeName) { - var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(hookAssemblyPath)); + var fullPath = Path.GetFullPath(hookAssemblyPath); + var loadContext = new ExtensionAssemblyLoadContext( + $"cdidx-hook-worker:{Path.GetFileNameWithoutExtension(fullPath)}", + fullPath); + var assembly = loadContext.LoadFromAssemblyPath(fullPath); var type = assembly.GetType(hookTypeName, throwOnError: true) ?? throw new InvalidOperationException($"hook type `{hookTypeName}` was not found."); return Activator.CreateInstance(type) as IPostExtractionHook diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs index 4113b636fe..3b1fe2603f 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Reflection; using System.Runtime.Loader; +using CodeIndex.Indexer.Extensibility; using CodeIndex.Diagnostics; using CodeIndex.Models; @@ -32,6 +33,7 @@ public sealed record PostExtractionHookDiscoverySnapshot( public sealed class PostExtractionHookRunner : IDisposable { + public const string HooksDirectoryEnvironmentVariable = "CDIDX_HOOKS_DIR"; public const string CallbackBudgetEnvironmentVariable = "CDIDX_HOOK_CALLBACK_BUDGET_MS"; public const string DiscoveryLimitEnvironmentVariable = "CDIDX_HOOK_DISCOVERY_MAX_DLLS"; public const string DiscoveryMaxBytesEnvironmentVariable = "CDIDX_HOOK_DISCOVERY_MAX_BYTES"; @@ -55,15 +57,27 @@ private PostExtractionHookRunner(List hooks, TimeSpan } public static PostExtractionHookRunner DiscoverDefault(long? maxFileSizeBytes = null) - => Discover(GetDefaultHooksDirectory(), maxFileSizeBytes); + { + var resolution = ResolveDefaultHooksDirectory(includeAcceptedOverrideDiagnostic: false); + return Discover(resolution.Directory, maxFileSizeBytes, resolution.Diagnostics); + } public static PostExtractionHookDiscoverySnapshot DiscoverDefaultMetadata() - => DiscoverMetadata(GetDefaultHooksDirectory()); + { + var resolution = ResolveDefaultHooksDirectory(includeAcceptedOverrideDiagnostic: true); + return DiscoverMetadata(resolution.Directory, resolution.Diagnostics); + } public static PostExtractionHookDiscoverySnapshot DiscoverMetadata(string? hooksDirectory) + => DiscoverMetadata(hooksDirectory, []); + + private static PostExtractionHookDiscoverySnapshot DiscoverMetadata( + string? hooksDirectory, + IReadOnlyList initialDiagnostics) { 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); @@ -82,9 +96,16 @@ public static PostExtractionHookDiscoverySnapshot DiscoverMetadata(string? hooks } public static PostExtractionHookRunner Discover(string? hooksDirectory, long? maxFileSizeBytes = null) + => Discover(hooksDirectory, maxFileSizeBytes, []); + + private static PostExtractionHookRunner Discover( + string? hooksDirectory, + long? maxFileSizeBytes, + IReadOnlyList initialDiagnostics) { var loaded = new List(); var runner = new PostExtractionHookRunner(loaded, ResolveCallbackBudget()); + runner.EnqueueDiagnostics(initialDiagnostics); var maxProtocolLineBytes = WorkerProtocolLineLimits.ResolveForSourceFileBytes(maxFileSizeBytes); if (string.IsNullOrWhiteSpace(hooksDirectory) || !Directory.Exists(hooksDirectory)) @@ -99,7 +120,9 @@ public static PostExtractionHookRunner Discover(string? hooksDirectory, long? ma if (!HookAssemblyCandidateIsWithinBudget(dllPath, runner, maxAssemblyBytes)) continue; - var loadContext = new AssemblyLoadContext($"cdidx-hook:{Path.GetFileNameWithoutExtension(dllPath)}", isCollectible: true); + var loadContext = new ExtensionAssemblyLoadContext( + $"cdidx-hook:{Path.GetFileNameWithoutExtension(dllPath)}", + dllPath); assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); } catch (Exception) @@ -269,6 +292,9 @@ private static bool TryMoveNextHookFile( public IReadOnlyList Hooks => hooks.Select(hook => hook.Info).ToList(); + internal IReadOnlyList LoadContextsForTests + => hooks.Select(hook => hook.LoadContext).ToList(); + public IReadOnlyList Diagnostics => diagnostics.ToList(); public TimeSpan CallbackBudget => callbackBudget; @@ -380,12 +406,13 @@ private void EnqueueDiagnostic( string? callback = null, long? durationMs = null) { - diagnostics.Enqueue(new PostExtractionHookDiagnostic( - DiagnosticSanitizer.ForPath(assemblyPath), - DiagnosticSanitizer.ForOptionalLabel(typeName), - DiagnosticSanitizer.ForMessage(message), - DiagnosticSanitizer.ForOptionalLabel(callback), - durationMs)); + diagnostics.Enqueue(CreateDiagnostic(assemblyPath, typeName, message, callback, durationMs)); + } + + private void EnqueueDiagnostics(IEnumerable items) + { + foreach (var item in items) + diagnostics.Enqueue(item); } private static TimeSpan ResolveCallbackBudget() @@ -485,18 +512,120 @@ private static void ReplaceList(IList target, IReadOnlyList replacement target.Add(item); } - private static string? GetDefaultHooksDirectory() + private static HookDirectoryResolution ResolveDefaultHooksDirectory(bool includeAcceptedOverrideDiagnostic) { - var overridePath = Environment.GetEnvironmentVariable("CDIDX_HOOKS_DIR"); + var overridePath = Environment.GetEnvironmentVariable(HooksDirectoryEnvironmentVariable); if (!string.IsNullOrWhiteSpace(overridePath)) - return overridePath; + return ResolveOverrideHooksDirectory(overridePath, includeAcceptedOverrideDiagnostic); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return string.IsNullOrWhiteSpace(home) - ? null - : Path.Combine(home, ".config", "cdidx", "hooks"); + return new HookDirectoryResolution( + string.IsNullOrWhiteSpace(home) + ? null + : Path.Combine(home, ".config", "cdidx", "hooks"), + []); } + private static HookDirectoryResolution ResolveOverrideHooksDirectory( + string overridePath, + bool includeAcceptedOverrideDiagnostic) + { + var diagnostics = new List(); + string fullPath; + try + { + fullPath = Path.GetFullPath(overridePath); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + diagnostics.Add(CreateDiagnostic( + overridePath, + null, + "Hook directory override rejected: path could not be resolved.")); + return new HookDirectoryResolution(null, diagnostics); + } + + try + { + var directoryInfo = new DirectoryInfo(fullPath); + if (!directoryInfo.Exists) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override rejected: directory does not exist.")); + return new HookDirectoryResolution(null, diagnostics); + } + + if ((directoryInfo.Attributes & FileAttributes.ReparsePoint) != 0 + || !string.IsNullOrEmpty(directoryInfo.LinkTarget)) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override rejected: symbolic links and reparse points are not supported.")); + return new HookDirectoryResolution(null, diagnostics); + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override rejected: directory could not be inspected.")); + return new HookDirectoryResolution(null, diagnostics); + } + + AddUnixPermissionDiagnostic(fullPath, diagnostics); + if (includeAcceptedOverrideDiagnostic) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override accepted: hook assemblies execute local extension code from this trusted directory.")); + } + + return new HookDirectoryResolution(fullPath, diagnostics); + } + + private static void AddUnixPermissionDiagnostic(string fullPath, List diagnostics) + { + if (OperatingSystem.IsWindows()) + return; + + try + { + var mode = File.GetUnixFileMode(fullPath); + if ((mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override warning: directory is group- or world-writable; only trusted users should be able to modify hook assemblies.")); + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + diagnostics.Add(CreateDiagnostic( + fullPath, + null, + "Hook directory override warning: directory permissions could not be inspected.")); + } + } + + private static PostExtractionHookDiagnostic CreateDiagnostic( + string assemblyPath, + string? typeName, + string message, + string? callback = null, + long? durationMs = null) + => new( + DiagnosticSanitizer.ForPath(assemblyPath), + DiagnosticSanitizer.ForOptionalLabel(typeName), + DiagnosticSanitizer.ForMessage(message), + DiagnosticSanitizer.ForOptionalLabel(callback), + durationMs); + public void Dispose() { if (disposed) @@ -525,4 +654,8 @@ private sealed record LoadedPostExtractionHook( PostExtractionHookInfo Info, AssemblyLoadContext? LoadContext, PostExtractionHookCallbackWorkerClient Worker); + + private sealed record HookDirectoryResolution( + string? Directory, + IReadOnlyList Diagnostics); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index b95ce8d2e2..b9d793a13e 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2977,6 +2977,8 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; + if (postExtractionHookSnapshot.Diagnostics.Count > 0) + status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 131b705c1f..4f3faf9079 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; using CodeIndex.Models; namespace CodeIndex.Database; @@ -890,6 +891,9 @@ public class StatusResult public List? GraphSupportedLanguages { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Hooks { get; set; } + [JsonPropertyName("hook_diagnostics")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? HookDiagnostics { get; set; } [JsonPropertyName("extractors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExtractorRegistryStatus? Extractors { get; set; } diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index 7f97bc3f0c..ab01c035c0 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -1,4 +1,9 @@ +using System.Reflection; +using System.Runtime.Loader; using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +[assembly: CdidxPlugin(ExtractorPluginRegistry.CurrentApiVersion, ExtractorPluginRegistry.CurrentApiVersion)] namespace CodeIndex.Tests; @@ -150,6 +155,30 @@ public void LoadPlugin_SkipsOversizeAssemblyCandidate() } } + [Fact] + public void LoadPlugin_LoadsExtractorAssemblyInCollectibleContext_3413() + { + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + + ExtractorPluginRegistry.LoadPluginForTests(Assembly.GetExecutingAssembly().Location); + + Assert.True(ExtractorPluginRegistry.TryGetSymbolExtractor("collectibledsl", out var extractor)); + var loadContext = Assert.Single(ExtractorPluginRegistry.PluginAssemblyLoadContextsForTests()); + Assert.True(loadContext.IsCollectible); + Assert.NotSame(AssemblyLoadContext.Default, loadContext); + Assert.Same(loadContext, AssemblyLoadContext.GetLoadContext(extractor.GetType().Assembly)); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + } + } + } + [Fact] public void LoadPatternConfigs_BoundsDiagnosticsAndCountsSkippedFiles() { @@ -343,3 +372,13 @@ private static string BuildPatternConfigWithOverlongScalar(string scalarName) }; } } + +public sealed class CollectiblePluginSymbolExtractor : ISymbolExtractor +{ + public string Language => "collectibledsl"; + + public IReadOnlyCollection FileExtensions => [".collectible"]; + + public IReadOnlyList Extract(long fileId, string source, ExtractionContext context) + => []; +} diff --git a/tests/CodeIndex.Tests/PostExtractionHookTests.cs b/tests/CodeIndex.Tests/PostExtractionHookTests.cs index cd62a270dd..59d1dced4d 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookTests.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; using CodeIndex.Indexer.Hooks; using CodeIndex.Models; @@ -49,6 +51,60 @@ public void Discover_LoadsHooksAndAllowsSymbolAndReferenceMutation() } } + [Fact] + public void Discover_LoadsHookAssemblyInCollectibleContext_3413() + { + var projectRoot = TestProjectHelper.CreateTempProject("post-extraction-hook-collectible-load"); + try + { + var hooksDir = Path.Combine(projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + File.Copy(Assembly.GetExecutingAssembly().Location, Path.Combine(hooksDir, "CodeIndex.Tests.dll")); + + AssertHookAssemblyLoadsInCollectibleContext(hooksDir); + } + finally + { + CollectUnloadedHookAssemblies(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void CallbackWorker_LoadsHookAssemblyInCollectibleContext_3413() + { + var request = new PostExtractionHookCallbackWorker.WorkerRequest( + nameof(IPostExtractionHook.OnSymbolsExtracted), + new FileContext("project", "src/App.cs", "/project/src/App.cs", "csharp"), + [], + null); + using var input = new StringReader(JsonSerializer.Serialize(request, PostExtractionHookCallbackWorker.JsonOptions) + Environment.NewLine); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var handled = PostExtractionHookCallbackWorker.TryRunCommand( + [ + PostExtractionHookCallbackWorker.CommandName, + Assembly.GetExecutingAssembly().Location, + typeof(LoadContextReportingPostExtractionHook).FullName!, + ], + input, + output, + error, + out var exitCode); + + Assert.True(handled); + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, error.ToString()); + var response = JsonSerializer.Deserialize( + output.ToString(), + PostExtractionHookCallbackWorker.JsonOptions); + Assert.NotNull(response); + Assert.Null(response.WorkerError); + Assert.Null(response.CallbackError); + Assert.Contains(response.Symbols!, symbol => symbol.Name == "CollectibleHookLoadContext"); + } + [Fact] public void CallbackExceptions_AreDiagnosticsAndDoNotBlockOtherHooks() { @@ -280,6 +336,65 @@ public void CallbackBudget_NormalizesInvalidAndTooLargeValues() } } + [Fact] + public void DiscoverDefaultMetadata_ReportsAcceptedHooksDirectoryOverride_3415() + { + var projectRoot = TestProjectHelper.CreateTempProject("post-extraction-hook-override-accepted"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable); + try + { + var hooksDir = Path.Combine(projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); + + var snapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); + + Assert.Empty(snapshot.Hooks); + Assert.Contains( + snapshot.Diagnostics, + diagnostic => diagnostic.AssemblyPath.EndsWith("hooks", StringComparison.Ordinal) + && diagnostic.Message.Contains("override accepted", StringComparison.Ordinal)); + Assert.All( + snapshot.Diagnostics, + diagnostic => Assert.DoesNotContain(projectRoot, diagnostic.AssemblyPath, StringComparison.Ordinal)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + + [Fact] + public void DiscoverDefaultMetadata_RejectsMissingHooksDirectoryOverride_3415() + { + var projectRoot = TestProjectHelper.CreateTempProject("post-extraction-hook-override-missing"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable); + try + { + var hooksDir = Path.Combine(projectRoot, "missing-hooks"); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); + + var snapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); + + Assert.Empty(snapshot.Hooks); + var diagnostic = Assert.Single(snapshot.Diagnostics); + Assert.EndsWith("missing-hooks", diagnostic.AssemblyPath, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, diagnostic.AssemblyPath, StringComparison.Ordinal); + Assert.Contains("override rejected", diagnostic.Message, StringComparison.Ordinal); + Assert.Contains("does not exist", diagnostic.Message, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void Discover_CapsHookAssemblyCandidates() { @@ -359,6 +474,18 @@ private static void CollectUnloadedHookAssemblies() GC.Collect(); } + private static void AssertHookAssemblyLoadsInCollectibleContext(string hooksDir) + { + using var runner = PostExtractionHookRunner.Discover(hooksDir); + + var loadContext = Assert.Single( + runner.LoadContextsForTests + .Where(context => context != null) + .Distinct()); + Assert.True(loadContext!.IsCollectible); + Assert.NotSame(AssemblyLoadContext.Default, loadContext); + } + private static void AssertFileDoesNotAppear(string path, TimeSpan duration) { var deadline = DateTimeOffset.UtcNow.Add(duration); @@ -523,3 +650,26 @@ private static void SignalCompletionWhenRequested() File.WriteAllText(completionPath, "done"); } } + +public sealed class LoadContextReportingPostExtractionHook : IPostExtractionHook +{ + public void OnSymbolsExtracted(FileContext context, IList symbols) + { + var loadContext = AssemblyLoadContext.GetLoadContext(GetType().Assembly); + if (loadContext is { IsCollectible: true } && !ReferenceEquals(loadContext, AssemblyLoadContext.Default)) + { + symbols.Add(new SymbolRecord + { + Kind = "domain_tag", + Name = "CollectibleHookLoadContext", + Line = 1, + StartLine = 1, + EndLine = 1, + }); + } + } + + public void OnReferencesExtracted(FileContext context, IList references) + { + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index 3f30dac5c7..8d48f95aee 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.Hooks; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -149,7 +150,7 @@ public void RunStatus_Json_ReportsHookCandidatesWithoutLoadingAssemblies_3142() var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_hook_metadata_3142"); lock (TestConsoleLock.Gate) { - using var env = EnvironmentVariableScope.Capture("CDIDX_HOOKS_DIR"); + using var env = EnvironmentVariableScope.Capture(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable); try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); @@ -157,7 +158,7 @@ public void RunStatus_Json_ReportsHookCandidatesWithoutLoadingAssemblies_3142() Directory.CreateDirectory(hooksDir); var hookPath = Path.Combine(hooksDir, "broken.dll"); File.WriteAllText(hookPath, "not a real assembly"); - env.Set("CDIDX_HOOKS_DIR", hooksDir); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( ["--db", dbPath, "--json"], @@ -178,6 +179,47 @@ public void RunStatus_Json_ReportsHookCandidatesWithoutLoadingAssemblies_3142() } } + [Fact] + public void RunStatus_Json_ReportsHookDiscoveryLimitDiagnostics_3456() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_hook_cap_3456"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, + PostExtractionHookRunner.DiscoveryLimitEnvironmentVariable); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var hooksDir = Path.Combine(projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + File.WriteAllText(Path.Combine(hooksDir, "a.dll"), "not a real assembly"); + File.WriteAllText(Path.Combine(hooksDir, "b.dll"), "not a real assembly"); + File.WriteAllText(Path.Combine(hooksDir, "c.dll"), "not a real assembly"); + env.Set(PostExtractionHookRunner.HooksDirectoryEnvironmentVariable, hooksDir); + env.Set(PostExtractionHookRunner.DiscoveryLimitEnvironmentVariable, "2"); + + 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); + Assert.Equal(2, document.RootElement.GetProperty("hooks").GetArrayLength()); + var diagnostic = Assert.Single( + document.RootElement.GetProperty("hook_diagnostics").EnumerateArray(), + item => item.GetProperty("message").GetString()!.Contains("candidate limit", StringComparison.Ordinal)); + Assert.EndsWith("hooks", diagnostic.GetProperty("assembly_path").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, diagnostic.GetProperty("assembly_path").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void RunStatus_Json_CapsSymbolKindCountsAndNames_3134() {