diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 3de518f6c..17e9c597d 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -143,6 +143,8 @@ CI watching must be bounded. Do not loop indefinitely. ## Status Contract +- Scoped-update `csharp_workspace_expansion` and successful `last_index_run.csharp_workspace_expansion` share bounded trigger/decision codes, target counts, and preflight costs. Preserve conservative fallback and all workspace snapshot guards when using the optional source-input fingerprint to narrow C# re-extraction; see `docs/csharp-update-expansion.md` for proof invalidation and measurement semantics. + - Size-cap omissions use the shared CLI/MCP partial outcome policy (#5295). `size_omissions` exposes up to 20 sanitized 512-character paths, authoritative count/truncation metadata, and known omission-time byte evidence. Never infer a larger admission policy from omitted file rows. Repair requires an explicit reviewed limit or deliberate exclusion followed by a full scan; preserve successful graph rows and keep freshness separate from completeness. - `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `DEVELOPER_GUIDE.md` (the README links to that reference), including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `graph_data_current`, `index_complete`, `index_incomplete_reasons`, `symbol_kind_filter_provenance_available`, `symbol_kind_filter`, `symbols_dropped_by_kind_filter`, `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`, `workspace_verified_head_sha`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `head_freshness`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `unknown_extension_group_count`, `unknown_extension_groups_truncated`, `unknown_extension_group_limit`, `unknown_extension_group_omitted_count`, `unknown_extension_guidance`, `extractors`, `git_executable`, `github_cli_executable`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, WAL checkpoint diagnostics (`read_only_fallback`, `wal_checkpoint_attempted`, `wal_checkpoint_succeeded`, `wal_checkpoint_skipped_reason`, `wal_checkpoint_failure_reason`, `wal_checkpoint_busy`, `wal_checkpoint_log_page_count`, `wal_checkpoint_checkpointed_page_count`, `wal_checkpoint_remaining_page_count`, `read_only_immutable_fallback`, `wal_stale_snapshot_risk`, `wal_stale_snapshot_reason`), `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`, `status_metadata_diagnostics`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ad6ecef0f..a0eb71297 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1,5 +1,14 @@ # Developer Guide +## C# scoped-update expansion (#5347) + +See [C# update scope diagnostics](docs/csharp-update-expansion.md#english) for the +immediate/persisted `csharp_workspace_expansion` contract, phase-cost semantics, +and conservative source-input proof. Narrow only after the expanded pre-write +barrier; keep all source/configuration snapshots for final validation. The optional +proof must be invalidated before mutation and on other successful indexing paths. + + ## Batch project-root snapshot reuse (#5339) Children inheriting a batch reader resolve the project root, persisted path-case @@ -4570,6 +4579,15 @@ API version 1 の互換性を維持し、新しい guard scope は contract vers # 開発者ガイド +## C# 部分更新の展開(#5347) + +直後の出力と保存済み `csharp_workspace_expansion` の契約、工程別コストの意味、 +保守的なソース入力証拠は [C# 更新範囲の診断](docs/csharp-update-expansion.md#日本語) +を参照してください。展開後の書き込み前検証を通過してから対象を絞り、全ソース・設定の +スナップショットを最終検証まで維持します。最適化用の証拠は変更前と、別の索引経路の +成功時に無効化してください。 + + ## batch のプロジェクトルート解決におけるスナップショット再利用(#5339) batch reader を継承する子コマンドは、プロジェクトルート、保存済みの大小文字区別設定、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 7a9a7a8d8..6b5f25d96 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -9,6 +9,20 @@ whole-query authority and page-window omissions cannot inherit page-only evidenc Cycle summary variants share the existing ranked-SCC fixture in `QueryCommandRunnerTests`; run these and dependency query regressions on net8/net9. +## C# update expansion coverage (#5347) + +Run `--filter FullyQualifiedName~Issue5347` on net8/net9 alongside the existing +C# update, source-evidence, snapshot-barrier and metadata-reader regressions. +Use one small contract/member/consumer fixture for independent edits and full-index +symbol/graph comparisons; preserve rename/deletion, configuration, cancellation, +and malformed persisted-telemetry controls. Include project-marker scope/budget changes, +case-only rename peak counts, and quiet full-scan fallbacks. Phase durations are diagnostics, not +wall-clock assertions. Tests using existing global update hooks stay in the SQLite +pool sensitive collection and restore hooks in `finally`. +Exercise marker creation and budget exhaustion between the initial and expanded +scans, comparing the same timing with a conservative database and full C# extraction. + + `Extract_CSharpStaticLambdaGate_BoundsRepeatedSameLineDeclarations` checks 64 same-line static methods with a warmed 2 MiB allocation ceiling on both runtimes, including C#, Razor, Blazor and CSHTML. Preserve all identities, raw start columns, @@ -1430,6 +1444,19 @@ limit 1/2/3/4、空結果・フィルター・グラフ欠落、batch の `QueryCommandRunnerTests` の既存の SCC 順位 fixture を共有し、依存クエリの回帰と 併せて net8/net9 で実行します。 +## C# 更新対象の展開テスト(#5347) + +`--filter FullyQualifiedName~Issue5347` を net8/net9 で、既存の C# 更新、ソース証拠、 +スナップショット検証、メタデータ読み取りの回帰テストとともに実行します。小さな契約・ +メンバー・利用側の共通フィクスチャで、独立した編集と全量索引のシンボル・グラフを比較し、 +rename・削除、設定変更、キャンセル、不正な保存済み診断を検証してください。プロジェクト +境界と探索上限の変更、大小文字だけの rename の最大件数、quiet 指定時の全走査への切り替えも含めます。工程時間は +診断値とし、実時間の閾値をテスト条件にしません。既存のグローバル更新フックを使うテストは +SQLite pool sensitive collection に配置し、`finally` で復元します。 +初期走査と展開走査の間でマーカーを追加する場合や探索上限に達する場合も検証し、 +同じタイミングで変化する保守的な DB 更新と、C# の全量抽出結果を比較してください。 + + #5339 は #5332 の fixture に正常な `files --format count --json` の batch を追加し、 3件・12件ともスナップショット3個分のコピー量を上限として、件数・鮮度・確定性の出力を検証します。 メタデータがある場合と欠落する場合の両方で、reader 取得後に元 DB のルート、大小文字区別設定、 diff --git a/changelog.d/unreleased/5347.changed.md b/changelog.d/unreleased/5347.changed.md new file mode 100644 index 000000000..ef7527220 --- /dev/null +++ b/changelog.d/unreleased/5347.changed.md @@ -0,0 +1,18 @@ +--- +category: changed +issues: + - 5347 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs + - src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs + - src/CodeIndex/Models/CSharpWorkspaceExpansion.cs + - docs/csharp-update-expansion.md +--- + +## English + +- **Explain C# incremental-update expansion and reduce proven independent work (#5347)** — scoped updates report bounded trigger/decision, original/expanded/final target counts, and preflight phase costs in immediate output and persisted status. Matching complete source-input evidence can narrow re-extraction while retaining workspace snapshot guards; missing evidence, contract/configuration changes, hooks, filters and incomplete inputs retain conservative processing. + +## 日本語 + +- **C# 差分更新の展開理由を示し、独立性を確認できた処理量を削減 (#5347)** — 部分更新の直後の出力と保存済み status に、上限付きの契機・判断、元/展開後/最終対象数、前処理の工程別コストを出力します。完全なソース入力の証拠が一致する場合は全体スナップショット検証を維持して再抽出対象を絞り、証拠不足、契約・設定変更、フック、フィルター、不完全な入力では保守的な処理を継続します。 diff --git a/docs/csharp-update-expansion.md b/docs/csharp-update-expansion.md new file mode 100644 index 000000000..99caaf053 --- /dev/null +++ b/docs/csharp-update-expansion.md @@ -0,0 +1,194 @@ +# C# incremental update scope (#5347) + +## English + +`index --files`, `--commits`, and `--changed-between` may need to refresh untouched +C# consumers when static-interface contracts or qualified member-read targets +change. The update result now includes `csharp_workspace_expansion`. Successful +runs persist the same object as `status.last_index_run.csharp_workspace_expansion`, +also available through MCP status. It is diagnostic evidence, not a readiness flag. + +| Field | Meaning | +| --- | --- | +| `trigger` | Why the C# preflight considered workspace work: persisted/source static-interface contracts, member-reference targets, incomplete contract evidence, ordinary C# targets, or no C# targets. Configuration/partial-index full-scan fallbacks identify their trigger too. | +| `decision`, `reason` | `expanded`, `narrowed`, `not_expanded`, `deferred`, or `full_scan`, with a stable reason code. | +| `original_target_count` | Resolved update paths, including Git reconciliation and deletion paths, before C# expansion. | +| `expanded_target_count` | Peak planned target count, including the original set, before any safe narrowing. | +| `final_target_count` | Paths handed to the update loop after preflight guards and narrowing. In a full-scan fallback this is the discovered file count. | +| `initial_prepass_ms` | Time for the initial C# target/evidence preflight. | +| `workspace_scan_ms`, `workspace_prepass_ms` | Time for expanded discovery and the authoritative C# source prepass. Zero means that phase did not run or rounded below one millisecond. Full-scan fallbacks do not run these scoped-update phases. | +| `workspace_prepass_file_count`, `workspace_prepass_input_bytes` | Number of C# prepass inputs and the sum of their captured file lengths. These are input-volume evidence, not measured physical I/O; generated-suppressed and over-limit files can be included. | + +Counts describe paths, not unique filesystem identities or successfully rewritten +rows. Use the result summary and `last_index_run.files_scanned`, `rows_upserted`, +`rows_deleted`, `bytes_read`, and `duration_ms` for completed update work. +`bytes_read` retains its existing file-loop meaning and does not include prepass +rereads. All three phase times exclude the file-update loop. Human update output +shows the decision and original → expanded → final counts. Full-scan fallback +summaries honor `--quiet`. + +The narrow path still scans and validates the whole C# workspace. It reduces +main-pass re-extraction only after a successful expanded update has established a +matching source-input fingerprint. The fingerprint includes every C# path, +generated suppression, complete contents of sources admitted by the existing +`static`/`enum`/`const` prepass gate and their contribution order, complete C# project-marker evidence, +observed configuration, extraction limits, +symlink policy, project root, and the exact binary build. For example, changing a +method body in an ordinary instance-only class can leave these contract inputs +unchanged. Changing a source containing `static`, even just a comment in that +source, remains conservative. This is not a general semantic comparison of C#. + +Project-marker evidence comes from the same expanded scan that establishes family +scopes and the validated input snapshot. Marker changes or discovery-budget +exhaustion between the initial and expanded scans retain full expansion. + +The proof is optional, limited to 50,000 C# paths, and bound to the last successful +run. Legacy/missing evidence (`baseline_unavailable`), changed inputs or binary +(`contract_inputs_changed`), incomplete or incompatible prior readiness, active +symbol filters, hooks, custom extractors/patterns, incomplete project-marker discovery, +prepass timeouts, and retained +out-of-scan symbols retain conservative work. Full scans and other successful +indexing paths clear the proof; no rebuild or migration is required to use existing +databases. Updates without an authoritative workspace prepass also clear it. Rename/deletion and configuration +transitions keep existing cleanup and full-scan fallback behavior. + +The expanded source/configuration snapshots still guard writes and final +readiness after narrowing. Detected drift defers C# mutation or marks the run +partial; cancellation does not advance successful-run provenance. Failed attempts +can expose immediate diagnostics while `last_index_run` continues to describe the +previous successful run. Readiness/completeness and HEAD verification retain their +existing independent meanings. Persisted diagnostics use the standard bounded +status metadata reader and reject malformed codes, negative costs, and inconsistent +counts without trusting them. + +### Measured example + +On macOS arm64, Debug/net8.0, parallelism 4, an isolated Git repository contained +99 C# files: 96 instance-only worker classes with 32 methods each, one static-interface +contract, its implementation, and a constant holder, plus one Markdown file. +After a full index and a successful Git-scoped refresh established a verified old +HEAD and source proof, another instance method changed. A SQLite backup of that +same old-ref database, with only the optimization proof removed, provided the +conservative control. Both ran `index . --changed-between --json` +with an explicit, private `--db`; the control was not an already-current database. + +| Case | Updated-loop files | Loop bytes | Persisted duration (ms) | Process wall (ms) | C# scan / prepass (ms) | +| --- | ---: | ---: | ---: | ---: | ---: | +| Independent instance edit, narrowed | 1 | 1,777 | 1,850 | 1,987 | 9 / 222 | +| Same edit, conservative control | 99 | 170,819 | 2,720 | 2,856 | 8 / 244 | +| Shared constant target renamed | 99 | 170,821 | 3,478 | 3,612 | 8 / 235 | + +All cases retained fresh, complete source/graph status and verified HEADs. The +independent result matched its conservative control; the shared-target result +matched a full rebuild of the same source. Prepass input volume remained about +171 KB in all cases. These single-run fixture measurements do not establish a +general speedup. + +### Historical workload reproduction + +A separate detached checkout reproduced the original workload using fresh Debug/net8.0 +builds at each historical ref. Its private root database was first fully indexed and +verified at `b84d28d20670cf41de7f310758953c9ea43efd51`, including root and manifest checks. +After switching to `36f2e68cb0d7f17bc9591d01ad3045abe870da9b` and rebuilding, this command ran: + +```sh +dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between b84d28d20670cf41de7f310758953c9ea43efd51 36f2e68cb0d7f17bc9591d01ad3045abe870da9b --db .cdidx/codeindex.db --json +``` + +The 24 Git paths expanded to 1,424 scanned paths, with 1,410 updated, 14 removed, +39,061,997 loop bytes, and 154,465 ms persisted duration (154,471 ms immediate elapsed). +Counts and bytes matched the issue's audit. Both freshness checks passed afterward, +with complete source/graph data and the new verified HEAD. This is a single-run +baseline reproduction on macOS arm64; the fixture above measures the narrowing behavior. + +## 日本語 + +`index --files`、`--commits`、`--changed-between` は、static interface 契約や +修飾付きメンバー読み取りの参照先が変わると、未変更の C# 利用側も更新する必要があります。 +更新結果の `csharp_workspace_expansion` にその判断を出力します。成功した実行では同じ +オブジェクトを `status.last_index_run.csharp_workspace_expansion` に保存し、MCP の +status からも参照できます。これは診断情報であり、準備状態のフラグではありません。 + +| フィールド | 意味 | +| --- | --- | +| `trigger` | 永続化済み/ソース上の static interface 契約、メンバー参照先、不完全な契約証拠、通常の C# 対象、C# 対象なしのいずれか。設定変更や以前の部分実行による全走査への切り替えも識別します。 | +| `decision`、`reason` | `expanded`(展開)、`narrowed`(絞り込み)、`not_expanded`(展開なし)、`deferred`(延期)、`full_scan`(全走査)と、機械処理用の理由コード。 | +| `original_target_count` | Git の照合追加分や削除パスを含む、C# 展開前の確定対象数。 | +| `expanded_target_count` | 元の集合を含む、安全な絞り込みを行う前の最大計画対象数。 | +| `final_target_count` | 前処理の検証と絞り込み後、更新ループへ渡すパス数。全走査への切り替えでは検出ファイル数。 | +| `initial_prepass_ms` | 最初の C# 対象・証拠確認にかかった時間。 | +| `workspace_scan_ms`、`workspace_prepass_ms` | 展開時の全体検出と、C# ソース前処理にかかった時間。0 は未実行または 1 ミリ秒未満です。全走査への切り替えでは、この部分更新用の各工程は実行しません。 | +| `workspace_prepass_file_count`、`workspace_prepass_input_bytes` | C# 前処理の入力数と、取得したファイル長の合計。実際の物理 I/O 計測値ではなく、生成コードの抽出抑制対象やサイズ上限超過ファイルも含み得ます。 | + +対象数はパス数であり、ファイル実体の一意数や書き換え成功行数ではありません。完了した +更新処理量は結果の summary と、`last_index_run` の `files_scanned`、`rows_upserted`、 +`rows_deleted`、`bytes_read`、`duration_ms` で確認します。`bytes_read` は従来どおり +ファイル更新ループの値で、前処理の再読込は含みません。3種類の工程時間も更新ループを +含みません。人間向けの更新出力には判断と、元の対象数 → 展開後 → 最終対象数を表示します。 +全走査への切り替え時の summary は `--quiet` に従います。 + +絞り込む場合も C# 全体の走査と検証を続けます。正常な全 C# 更新で作成したソース入力の +指紋と一致した場合だけ、本処理で再抽出する対象を減らします。指紋には全 C# パス、 +生成コードの抽出抑制、既存の `static`/`enum`/`const` 候補判定を通過するソースの全文と順序、 +完全な C# プロジェクトマーカーの証拠、観測した設定、抽出上限、シンボリックリンク方針、 +プロジェクトルート、実行バイナリの +ビルドを含めます。通常のインスタンス専用クラスのメソッド本体の変更は、これらの入力を +変えない場合があります。一方、`static` を含むソースの変更は、そのファイルのコメント +編集だけでも保守的に処理します。C# 全般の意味的な同値性を判定する機能ではありません。 + +プロジェクトマーカーの証拠は、family scope と検証対象の入力スナップショットを確立する +同一の展開走査から取得します。初期走査と展開走査の間にマーカーが変わるか、探索上限に +達した場合も、全体への展開を維持します。 + +この証拠は最適化専用で、C# パス数の上限は 50,000、対象世代は直近の成功実行です。 +古い DB などで証拠がない場合(`baseline_unavailable`)、入力やバイナリが変わった場合 +(`contract_inputs_changed`)、以前の準備状態が不完全・非互換の場合、シンボルフィルター、 +フック、独自の抽出器・パターン、プロジェクトマーカー探索の不完全性、前処理タイムアウト、走査対象外に保持したシンボルが +ある場合は保守的に処理します。全走査など別経路での正常な索引更新と、全体のソース前処理をしない更新は +証拠を消去します。既存 DB の利用に再構築や移行は不要です。rename・削除時の後始末と、 +設定変更時に全走査へ切り替える動作を維持します。 + +絞り込み後も、全体のソース・設定スナップショットで書き込み前と最終準備状態を検証します。 +変化を検出した場合は C# 更新を延期するか部分実行として扱い、キャンセル時に成功実行の +来歴を進めません。失敗直後の結果に今回の診断を出しても、`last_index_run` は前回の +成功実行を示し続けます。準備状態・完全性と HEAD 検証は従来どおり独立した意味を保ちます。 +保存済み診断は既存の上限付き status メタデータ読み取りを使い、不正なコード、負のコスト、 +矛盾した対象数を拒否します。 + +### 実測例 + +macOS arm64、Debug/net8.0、並列度4で、分離した Git リポジトリを測定しました。 +C# は99ファイル(32メソッドずつのインスタンス専用クラス96個、static interface 契約、 +実装型、定数保持型)で、別に Markdown が1ファイルあります。全量索引と正常な Git 範囲更新で +旧 HEAD の鮮度とソース証拠を確立してから、別のインスタンスメソッドを編集しました。 +同じ旧参照の DB を SQLite backup で複製し、最適化用の証拠だけを除去して保守的な対照実行に +使いました。両方とも専用の `--db` を明示し、`index . --changed-between --json` +を実行しています。対照側を更新済み DB で代用していません。 + +| ケース | 更新ループのファイル数 | ループの bytes | 保存済み時間 (ms) | プロセス実時間 (ms) | C# 走査/前処理 (ms) | +| --- | ---: | ---: | ---: | ---: | ---: | +| 独立したインスタンス編集・絞り込みあり | 1 | 1,777 | 1,850 | 1,987 | 9 / 222 | +| 同じ編集・保守的な対照実行 | 99 | 170,819 | 2,720 | 2,856 | 8 / 244 | +| 共有定数の参照先名を変更 | 99 | 170,821 | 3,478 | 3,612 | 8 / 235 | + +全ケースでソース・グラフの鮮度と完全性、検証済み HEAD を維持しました。独立した編集の +結果は保守的な対照実行と一致し、共有参照先の変更結果は同じソースの全量再構築と一致しました。 +前処理の入力は全ケースで約171 KBのままです。このフィクスチャの各1回の実測は、一般的な +高速化率を保証するものではありません。 + +### 過去の workload の再現 + +別の detached checkout で、各参照の Debug/net8.0 バイナリを新しくビルドして再現しました。 +専用のルート DB を `b84d28d20670cf41de7f310758953c9ea43efd51` で全量索引し、ルートと +manifest の鮮度を確認してから、`36f2e68cb0d7f17bc9591d01ad3045abe870da9b` へ切り替え、 +再ビルドして次を実行しました。 + +```sh +dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between b84d28d20670cf41de7f310758953c9ea43efd51 36f2e68cb0d7f17bc9591d01ad3045abe870da9b --db .cdidx/codeindex.db --json +``` + +Git の24パスが1,424パスへ展開され、1,410更新、14削除、ループ内39,061,997バイト、 +保存済み時間154,465 ms(直後の elapsed は154,471 ms)でした。件数とバイト数は Issue の +監査記録と一致しました。更新後の両方の鮮度確認も通過し、ソース・グラフの完全性と新しい +検証済み HEAD を確認しました。これは macOS arm64 での各1回のベースライン再現です。 +絞り込みの動作は上記フィクスチャで測定しています。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs b/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs index 6344e0bbe..53a387e0c 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs @@ -133,7 +133,8 @@ private static void StampLastIndexRunMetadata( IndexMemoryTimelineJsonResult? memoryTimeline, IReadOnlyList? diagnostics, ReferenceExtractionCapHitSummary? referenceExtractionCapHits, - IReadOnlyList? indexIncompleteReasons) + IReadOnlyList? indexIncompleteReasons, + CSharpWorkspaceExpansion? csharpExpansion = null) { writer.SetMetaValues( (DbContext.LastIndexRunModeMetaKey, mode), @@ -151,6 +152,10 @@ private static void StampLastIndexRunMetadata( ? null : JsonSerializer.Serialize(referenceExtractionCapHits, StatusMetadataJsonContext.Default.ReferenceExtractionCapHitSummary)), (DbContext.LastIndexRunRebuildReclaimMetaKey, null), + (DbContext.LastIndexRunCSharpWorkspaceExpansionMetaKey, csharpExpansion == null + ? null + : JsonSerializer.Serialize(csharpExpansion, StatusMetadataJsonContext.Default.CSharpWorkspaceExpansion)), + (DbContext.CSharpWorkspaceContractBaselineMetaKey, null), (DbContext.LastIndexRunPeakMemoryMbMetaKey, memoryTimeline == null ? null : (memoryTimeline.PeakWorkingSetBytes / (1024 * 1024)).ToString(System.Globalization.CultureInfo.InvariantCulture))); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs index be88df89d..2c2e45f26 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs @@ -63,6 +63,8 @@ private static int WriteFullScanFinalOutput(FullScanFinalOutputContext output) output.Stopwatch.Stop(); var memoryTimeline = BuildMemoryTimeline(output.MemorySamples); WarnIfMemoryThresholdExceeded(memoryTimeline); + if (!output.Options.Json && !output.Options.Quiet) + WriteCSharpWorkspaceExpansionSummary(output.Options.CSharpWorkspaceExpansion); // Detect cwd drift between option-parsing and finalize. See RunUpdateMode for the // rationale; the warning is informational because we already absolutized paths. // Issue #1577. @@ -148,6 +150,7 @@ private static int WriteFullScanFinalOutput(FullScanFinalOutputContext output) { CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult { + CSharpWorkspaceExpansion = output.Options.CSharpWorkspaceExpansion, Status = partial ? "partial" : "success", Mode = output.Options.Rebuild ? "rebuild" : "incremental", UnknownExtensionFileCount = output.ScanResult.UnknownExtensionFiles.Count, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs index 0d3b921f0..0d3ceb4bc 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs @@ -302,7 +302,8 @@ context.SkippedSymbolExtractorLanguages is null memoryTimelineForStamp, context.IndexRunDiagnostics, writer.GetReferenceExtractionCapHits(issuesTableAvailableAfter), - writer.GetPersistedIndexOmissionReasons()); + writer.GetPersistedIndexOmissionReasons(), + options.CSharpWorkspaceExpansion); } return new FullScanReadinessResult( diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs index a96e3f1d1..afa87b1f1 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs @@ -105,6 +105,7 @@ private static int WriteFullScanSnapshotFailure( { CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult { + CSharpWorkspaceExpansion = failure.Options.CSharpWorkspaceExpansion, Status = "partial", Mode = failure.Options.Rebuild ? "rebuild" : "incremental", UnknownExtensionFileCount = failure.UnknownExtensionFiles.Count, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 7e2f143cf..f02faa152 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -134,6 +134,11 @@ void WriteProjectRootOnce() currentHotspotFamilyMarkerFingerprints); var scanHadErrors = scanResult.HadErrors; var files = discovery.Files; + if (options.CSharpWorkspaceExpansion is { } expansion) + { + expansion.ExpandedTargetCount = Math.Max(expansion.OriginalTargetCount, files.Count); + expansion.FinalTargetCount = files.Count; + } var languageCounts = scanResult.LanguageCounts; var csharpPrepassCapacity = languageCounts.TryGetValue("csharp", out var csharpFileCount) ? csharpFileCount : 0; var targetPreparation = PrepareFullScanTargets( diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpNarrowing.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpNarrowing.cs new file mode 100644 index 000000000..2ccea1126 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpNarrowing.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Security.Cryptography; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static void EvaluateUpdateCSharpContractNarrowing( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + var telemetry = context.Options.CSharpWorkspaceExpansion!; + if (!state.CapturedContractFingerprint + || !context.ContractNarrowingAllowed() + || state.ProjectMarkerFingerprint is not { } projectMarkerFingerprint + || state.CSharpWorkspace.ContractSourceFingerprint == null + || state.CSharpWorkspaceInputSnapshot is not { IsComplete: true } inputs + || state.CSharpWorkspaceSnapshots == null + || !state.CSharpWorkspace.SourceContractEvidenceComplete) + return; + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + void Add(string value) => CSharpWorkspaceContractFingerprint.Append(hash, value); + Add("csharp_workspace_inputs_v2"); + // A new binary must earn its own proof, even if its public version is unchanged. + Add(typeof(IndexCommandRunner).Module.ModuleVersionId.ToString("D")); + Add(context.ProjectRoot); + Add(state.CSharpWorkspace.ContractSourceFingerprint); + // Project markers change family scopes even when every C# path and + // contributing source is unchanged. This evidence has its own scan budget. + Add(projectMarkerFingerprint); + Add(context.Options.MaxFileSizeBytes!.Value.ToString(CultureInfo.InvariantCulture)); + Add(context.Options.MaxSymbolsPerFile.ToString(CultureInfo.InvariantCulture)); + Add(context.Options.MaxReferencesPerFile.ToString(CultureInfo.InvariantCulture)); + Add(context.Options.SymlinkPolicy.ToString()); + foreach (var pattern in context.Options.GeneratedCodePatterns) + Add(pattern); + Add("configuration_inputs"); + foreach (var input in inputs.ConfigurationInputs.OrderBy(input => input.Path, StringComparer.Ordinal)) + { + context.CancellationToken.ThrowIfCancellationRequested(); + Add(input.Path); + Add(input.Kind.ToString()); + Add(input.Length.ToString(CultureInfo.InvariantCulture)); + if (input.Kind != FileIndexer.ConfigurationInputKind.MarkerDirectory) + Add(input.ModifiedUtc.ToString("O", CultureInfo.InvariantCulture)); + Add(input.ContentHash == null ? "" : Convert.ToHexString(input.ContentHash)); + } + state.ContractBaselineFingerprint = Convert.ToHexString(hash.GetHashAndReset()); + + using var command = context.Writer.Connection.CreateCommand(); + command.CommandText = """ + SELECT value FROM codeindex_meta + WHERE key = $key AND length(CAST(value AS BLOB)) <= 256 + """; + command.Parameters.AddWithValue("$key", DbContext.CSharpWorkspaceContractBaselineMetaKey); + var priorBaseline = command.ExecuteScalar() as string; + command.Parameters["$key"].Value = DbContext.LastIndexRunStartedAtMetaKey; + var priorRun = command.ExecuteScalar() as string; + if (priorBaseline == null || priorRun == null) + { + telemetry.Reason = "baseline_unavailable"; + return; + } + + state.CanNarrowTargets = string.Equals( + priorBaseline, + BuildCSharpContractBaseline(priorRun, state.ContractBaselineFingerprint), + StringComparison.Ordinal); + telemetry.Reason = state.CanNarrowTargets ? "contract_inputs_unchanged" : "contract_inputs_changed"; + // Keep the expanded set until the existing pre-write guard validates every + // source and configuration input. The caller narrows only after that barrier. + } + + private static string BuildCSharpContractBaseline(string runStartedAt, string fingerprint) + => $"{runStartedAt}|{fingerprint}"; + + private static void WriteCSharpWorkspaceExpansionSummary(CSharpWorkspaceExpansion? expansion) + { + if (expansion == null) + return; + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine( + "C# update scope", + $"{expansion.Decision}: {expansion.OriginalTargetCount:N0} -> {expansion.ExpandedTargetCount:N0} -> {expansion.FinalTargetCount:N0} targets ({expansion.Trigger}; {expansion.Reason})", + indent: " ")); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs index 360d90d47..babf5ecc0 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CodeIndex.Database; using CodeIndex.Indexer; @@ -18,6 +19,7 @@ private sealed class UpdateCSharpPreflightContext internal required bool ScopedCleanupHadCSharp { get; init; } internal required bool ScopedCleanupHadContract { get; init; } internal required bool HadIndexedCSharpFilesBeforeUpdate { get; init; } + internal required Func ContractNarrowingAllowed { get; init; } internal required int Updated { get; init; } internal required int Removed { get; init; } internal required CancellationToken CancellationToken { get; init; } @@ -76,6 +78,10 @@ internal bool PreserveConservativePersistedContractEvidence } internal bool CSharpTargetAffected { get; set; } + internal string? ContractBaselineFingerprint { get; set; } + internal string? ProjectMarkerFingerprint { get; set; } + internal bool CanNarrowTargets { get; set; } + internal bool CapturedContractFingerprint { get; set; } } private sealed record UpdateCSharpPreflightResult( @@ -88,12 +94,16 @@ private sealed record UpdateCSharpPreflightResult( bool DeferCSharpMutationsForIncompleteWorkspace, bool? CSharpSourceEvidenceForStamp, bool CSharpSourceEvidenceCompleteForStamp, - bool CSharpTargetAffected); + bool CSharpTargetAffected, + string? ContractBaselineFingerprint, + bool CanNarrowTargets); private static UpdateCSharpPreflightResult PrepareUpdateCSharpWorkspace( UpdateCSharpPreflightContext context) { context.ThrowIfUpdateCancelled(); + var telemetry = context.Options.CSharpWorkspaceExpansion!; + var initialTimer = Stopwatch.StartNew(); WriteIndexJsonLiveness( context.Options, "checking C# workspace contracts..."); @@ -128,11 +138,42 @@ private static UpdateCSharpPreflightResult PrepareUpdateCSharpWorkspace( finally { StopIndexJsonPhaseHeartbeat(heartbeat); + telemetry.InitialPrepassMs = initialTimer.ElapsedMilliseconds; } + telemetry.Trigger = !state.CSharpWorkspace.SourceContractEvidenceComplete + ? "incomplete_contract_evidence" + : state.PreserveConservativePersistedContractEvidence + ? "persisted_static_interface_contracts" + : state.CSharpWorkspace.RequiresMemberReadReferenceRefresh + ? "member_reference_targets" + : state.CSharpWorkspace.HasStaticInterfaceContracts + ? "source_static_interface_contracts" + : state.CSharpTargetAffected ? "csharp_targets" : "no_csharp_targets"; if (state.CSharpWorkspace.HasStaticInterfaceContracts || state.CSharpWorkspace.RequiresMemberReadReferenceRefresh) + { + telemetry.Decision = "expanded"; + telemetry.Reason = "safety_checks_required"; ExpandUpdateCSharpWorkspace(context, state); + if (state.DeferCSharpMutationsForIncompleteWorkspace) + { + telemetry.Decision = "deferred"; + telemetry.Reason = "incomplete_workspace"; + } + else + { + EvaluateUpdateCSharpContractNarrowing(context, state); + } + } + else + { + telemetry.Reason = state.CSharpTargetAffected ? "no_workspace_contracts" : "no_csharp_targets"; + } + telemetry.FinalTargetCount = context.TargetPaths.Count; + if (telemetry.Decision == "expanded" + && telemetry.ExpandedTargetCount == telemetry.OriginalTargetCount) + telemetry.Decision = "not_expanded"; return new UpdateCSharpPreflightResult( state.ScannedUpdateLanguages, @@ -143,7 +184,9 @@ private static UpdateCSharpPreflightResult PrepareUpdateCSharpWorkspace( state.DeferCSharpMutationsForIncompleteWorkspace, state.CSharpSourceEvidenceForStamp, state.CSharpSourceEvidenceCompleteForStamp, - state.CSharpTargetAffected); + state.CSharpTargetAffected, + state.ContractBaselineFingerprint, + state.CanNarrowTargets); } private static void BuildInitialUpdateCSharpWorkspace( @@ -314,9 +357,11 @@ private static void ExpandUpdateCSharpWorkspace( try { UpdateCSharpExpansionScanStartingForTesting?.Invoke(); + var scanTimer = Stopwatch.StartNew(); var scanWithDirectorySnapshots = context.Indexer.ScanFilesDetailedWithDirectoryListingSnapshots( cancellationToken: context.CancellationToken); + context.Options.CSharpWorkspaceExpansion!.WorkspaceScanMs = scanTimer.ElapsedMilliseconds; var scanResult = scanWithDirectorySnapshots.ScanResult; state.CSharpWorkspaceInputSnapshot = scanWithDirectorySnapshots.InputSnapshot; @@ -330,8 +375,17 @@ private static void ExpandUpdateCSharpWorkspace( return; } + // Bind the proof to the scan that establishes the family-scope cache + // and validated snapshots, rather than the earlier marker-only scan. + state.ProjectMarkerFingerprint = scanResult.ProjectMarkerFingerprints.TryGetValue("csharp", out var projectMarkers) + && projectMarkers.IsComplete ? projectMarkers.Fingerprint : null; AddExpandedUpdateCSharpTargets(context, state, scanResult); + context.Options.CSharpWorkspaceExpansion.ExpandedTargetCount = Math.Max( + context.Options.CSharpWorkspaceExpansion.OriginalTargetCount, + context.TargetPaths.Count); + var prepassTimer = Stopwatch.StartNew(); BuildExpandedUpdateCSharpWorkspace(context, state); + context.Options.CSharpWorkspaceExpansion.WorkspacePrepassMs = prepassTimer.ElapsedMilliseconds; } catch (OperationCanceledException) when ( context.CancellationToken.IsCancellationRequested) @@ -397,6 +451,9 @@ private static void BuildExpandedUpdateCSharpWorkspace( out var beforeSnapshots, out var snapshotFailurePath, cancellationToken); + var telemetry = context.Options.CSharpWorkspaceExpansion!; + telemetry.WorkspacePrepassFileCount = state.CSharpPrepassTargets.Count; + telemetry.WorkspacePrepassInputBytes = beforeSnapshots.Values.Sum(snapshot => snapshot.Size); if (state.CSharpPrepassTargets.Count == 0) { state.CSharpWorkspace = @@ -413,6 +470,8 @@ private static void BuildExpandedUpdateCSharpWorkspace( else { UpdateCSharpPrepassForTesting?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + state.CapturedContractFingerprint = context.ContractNarrowingAllowed(); state.CSharpWorkspace = CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( context.Writer, @@ -426,7 +485,8 @@ private static void BuildExpandedUpdateCSharpWorkspace( excludedExistingFileIds: context.ScopedCleanupPlan.FileIds, patternConfigsAlreadyLoaded: true, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken, + captureContractFingerprint: state.CapturedContractFingerprint); } string? afterSnapshotFailurePath = null; diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs index 99dee148a..100ad6529 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs @@ -88,6 +88,7 @@ private static int WriteUpdateFinalOutput(UpdateFinalOutputContext output) { CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult { + CSharpWorkspaceExpansion = output.Options.CSharpWorkspaceExpansion, Status = partial ? "partial" : "success", Mode = "update", Summary = new IndexUpdateSummaryJsonResult @@ -163,6 +164,7 @@ private static int WriteUpdateFinalOutput(UpdateFinalOutputContext output) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Updated", ConsoleUi.FormatNumber(output.Updated), indent: " ")); + WriteCSharpWorkspaceExpansionSummary(output.Options.CSharpWorkspaceExpansion); if (output.Removed > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Removed", ConsoleUi.FormatNumber(output.Removed), indent: " ")); if (output.Skipped > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", ConsoleUi.FormatNumber(output.Skipped), indent: " ")); if (output.Warnings > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(output.Warnings), indent: " ")); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs index 2b7c45add..d8f5e9b26 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs @@ -64,6 +64,7 @@ private static int WriteUpdateSnapshotFailure( { CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult { + CSharpWorkspaceExpansion = failure.Options.CSharpWorkspaceExpansion, Status = "partial", Mode = "update", Summary = new IndexUpdateSummaryJsonResult diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 0b7a18f19..53c436c92 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -98,6 +98,13 @@ private static int RunUpdateMode( // static-interface refresh が全 C# を追加しても、rename cleanup / changed-between // purge の計画対象は caller が選んだ path のまま固定する。 var originalTargetPaths = targetPaths.ToArray(); + var csharpExpansion = new CSharpWorkspaceExpansion + { + OriginalTargetCount = targetPaths.Count, + ExpandedTargetCount = targetPaths.Count, + FinalTargetCount = targetPaths.Count, + }; + options.CSharpWorkspaceExpansion = csharpExpansion; var typeScriptJavaScriptConfigChanged = ContainsJavaScriptTypeScriptConfigPath(targetPaths); var extractorConfigurationChanged = ContainsExtractorConfigurationPath(projectRoot, targetPaths); @@ -109,6 +116,9 @@ private static int RunUpdateMode( || extractorConfigurationChanged || ambiguousLanguageProjectMarkerChanged) { + csharpExpansion.Decision = "full_scan"; + csharpExpansion.Trigger = csharpExpansion.Reason = priorScopedUpdateRequiresFullScan + ? "prior_partial_index" : "configuration_changed"; if (extractorConfigurationChanged) ExtractorPluginRegistry.ReloadPatternConfigsForProjectRoot(projectRoot); @@ -445,6 +455,20 @@ int PurgeStaleUpdateCleanupPaths( ScopedCleanupHadContract = scopedCleanupHadContract, HadIndexedCSharpFilesBeforeUpdate = hadIndexedCSharpFilesBeforeUpdate, + ContractNarrowingAllowed = () => priorIndexComplete + && priorSymbolExtractorVersionsMatchCurrent + && referenceIdentityContractMatchedBeforeMutation + && csharpSymbolNameContractMatchesCurrent + && priorMetadataTargetCsharpMatchesCurrent + && projectRootWritten + && !options.SymbolsOnly + && !options.SymbolKindFilter.IsActive + && !postExtractionHooks.Value.HasHooks + && ExtractorPluginRegistry.GetStatusSnapshot(projectRoot) is + { + PluginAssemblyCount: 0, PatternConfigCount: 0, SymbolExtractorCount: 0, + ReferenceExtractorCount: 0, DiagnosticCount: 0 + }, Updated = updated, Removed = removed, CancellationToken = cancellationToken, @@ -496,6 +520,9 @@ int PurgeStaleUpdateCleanupPaths( }); if (csharpMutationGuard.InputSnapshotFailurePath != null) { + csharpExpansion.Decision = "deferred"; + csharpExpansion.Reason = "incomplete_workspace"; + csharpExpansion.FinalTargetCount = 0; return WriteUpdateSnapshotFailure( csharpMutationGuard.InputSnapshotFailurePath, new UpdateSnapshotFailureContext @@ -538,6 +565,18 @@ int PurgeStaleUpdateCleanupPaths( csharpWorkspaceSnapshots = csharpMutationGuard.CSharpWorkspaceSnapshots; csharpWorkspace = csharpMutationGuard.CSharpWorkspace; + if (deferCSharpMutationsForIncompleteWorkspace) + { + csharpExpansion.Decision = "deferred"; + csharpExpansion.Reason = "incomplete_workspace"; + } + else if (csharpPreflight.CanNarrowTargets && targetPaths.Count > originalTargetPaths.Length) + { + targetPaths.Clear(); + targetPaths.UnionWith(originalTargetPaths); + csharpExpansion.Decision = "narrowed"; + } + csharpExpansion.FinalTargetCount = targetPaths.Count; bool TryValidateCSharpWorkspaceInputSnapshot( out string? changedPath) @@ -572,6 +611,7 @@ bool TryValidateCSharpWorkspaceInputSnapshot( using var referenceGraphRefresh = writer.BeginReferenceGraphRefreshScope(); using var hotspotAggregateRefresh = writer.BeginDeferredHotspotReferenceAggregateRefresh(); mutationPhaseStarted = true; + writer.SetMeta(DbContext.CSharpWorkspaceContractBaselineMetaKey, null); // Preflight errors are recorded before the scan barrier, where readiness writes are // forbidden. Once that barrier succeeds, demote before recovery/evidence or the // later partial-run metadata could leave Issues/Fold readiness falsely authoritative. @@ -1002,7 +1042,15 @@ bool TryValidateCSharpWorkspaceInputSnapshot( memoryTimelineForStamp, indexRunDiagnostics, writer.GetReferenceExtractionCapHits(issuesTableAvailableAfter), - writer.GetPersistedIndexOmissionReasons()); + writer.GetPersistedIndexOmissionReasons(), + csharpExpansion); + if (!csharpWorkspaceDriftDetected + && !deferCSharpMutationsForIncompleteWorkspace + && csharpPreflight.ContractBaselineFingerprint is { } contractFingerprint) + { + writer.SetMeta(DbContext.CSharpWorkspaceContractBaselineMetaKey, + BuildCSharpContractBaseline(runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), contractFingerprint)); + } successMetadataTxn.Commit(); } return WriteUpdateFinalOutput(new UpdateFinalOutputContext diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 4f954fe4a..60469f405 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -701,6 +701,8 @@ reason is "file_index_error" public sealed class IndexCommandOptions { + internal CSharpWorkspaceExpansion? CSharpWorkspaceExpansion { get; set; } + internal IndexCommandOptions WithResolvedFileSizeLimit(long limit) { var copy = (IndexCommandOptions)MemberwiseClone(); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 287b81b09..7aef25da2 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -1131,6 +1131,9 @@ public sealed class IndexSymbolKindFilterJsonResult internal sealed class IndexUpdateJsonResult : IVersionedJsonResult { + [JsonPropertyName("csharp_workspace_expansion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CSharpWorkspaceExpansion? CSharpWorkspaceExpansion { get; init; } public string ApiVersion { get; init; } = JsonOutputContract.ApiVersion; public string Status { get; init; } = string.Empty; public string Mode { get; init; } = string.Empty; @@ -1177,6 +1180,9 @@ internal sealed class IndexUpdateJsonResult : IVersionedJsonResult internal sealed class IndexFullScanJsonResult : IVersionedJsonResult { + [JsonPropertyName("csharp_workspace_expansion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CSharpWorkspaceExpansion? CSharpWorkspaceExpansion { get; init; } public string ApiVersion { get; init; } = JsonOutputContract.ApiVersion; public string Status { get; init; } = string.Empty; public string Mode { get; init; } = string.Empty; diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index ff93a07b8..c2eca0320 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -173,6 +173,8 @@ public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) public const string LastIndexRunDiagnosticsTruncatedMetaKey = "last_index_run_diagnostics_truncated"; public const string LastIndexRunReferenceExtractionCapHitsMetaKey = "last_index_run_reference_extraction_cap_hits_json"; public const string LastIndexRunRebuildReclaimMetaKey = "last_index_run_rebuild_reclaim_json"; + public const string LastIndexRunCSharpWorkspaceExpansionMetaKey = "last_index_run_csharp_workspace_expansion_json"; + internal const string CSharpWorkspaceContractBaselineMetaKey = "csharp_workspace_contract_baseline"; public const int LastIndexRunDiagnosticSampleLimit = 50; public const string LastFailedIndexRunStatusMetaKey = "last_failed_index_run_status"; public const string LastFailedIndexRunModeMetaKey = "last_failed_index_run_mode"; diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 4687f396c..b0d4c4bfd 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -726,11 +726,17 @@ private long ExecuteScalar(string sql) StatusMetadataJsonContext.Default.StatusRebuildReclaim, ValidateRebuildReclaim, metadataDiagnostics); + var csharpExpansion = ReadStructuredStatusMetadata( + DbContext.LastIndexRunCSharpWorkspaceExpansionMetaKey, + "last_index_run.csharp_workspace_expansion", + StatusMetadataJsonContext.Default.CSharpWorkspaceExpansion, + static value => value.IsValid(), + metadataDiagnostics); if (mode == null && startedAt == null && durationMs == null && filesScanned == null && filesSkipped == null && parseErrors == null && bytesRead == null && bytesReadSkippedFileCount == null && bytesReadIncomplete == null && rowsUpserted == null && rowsDeleted == null && peakMemoryMb == null && diagnostics == null && diagnosticCount == null && diagnosticsTruncated == null - && referenceExtractionCapHits == null && rebuildReclaim == null) + && referenceExtractionCapHits == null && rebuildReclaim == null && csharpExpansion == null) { return null; } @@ -754,6 +760,7 @@ private long ExecuteScalar(string sql) DiagnosticsTruncated = diagnosticsTruncated, ReferenceExtractionCapHits = referenceExtractionCapHits, RebuildReclaim = rebuildReclaim, + CSharpWorkspaceExpansion = csharpExpansion, }; } diff --git a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs index 4d98ed27a..fa3d93657 100644 --- a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs +++ b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs @@ -27,10 +27,16 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( bool loadExistingSymbolsOnlyForPendingQualifiedMemberAccess = false, bool patternConfigsAlreadyLoaded = false, CancellationToken cancellationToken = default, - CSharpPrepassSymbolArtifactCache? symbolArtifactCache = null) + CSharpPrepassSymbolArtifactCache? symbolArtifactCache = null, + bool captureContractFingerprint = false) { var targetCount = fileTargets.TryGetNonEnumeratedCount(out var count) ? count : 0; + captureContractFingerprint &= targetCount <= CSharpWorkspaceContractFingerprint.MaxFiles; var candidates = new List(targetCount); + var fingerprintTargets = captureContractFingerprint + ? new List<(string Path, bool Suppressed)>(targetCount) + : null; + var fingerprintComplete = 1; var pendingPaths = includeExistingSymbols ? new HashSet(targetCount, StringComparer.Ordinal) : null; @@ -68,7 +74,10 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( // 再利用可能な未変更C#行はworkspace lookupに保持し、今回の抽出で置換・ // suppressionされるpathだけをpendingPathsへ入れる。 if (includeExistingSymbols && canReuseExistingSymbolsWithoutRead?.Invoke(target) == true) + { + fingerprintComplete = 0; continue; + } if (canExcludeExistingPath) pendingPaths!.Add(target.IndexPath); @@ -76,11 +85,20 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( var generatedExtractionSuppressed = isGeneratedCodeExtractionSuppressed?.Invoke(target) ?? target.GeneratedExtractionSuppressed ?? indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath); + if (fingerprintTargets?.Count == CSharpWorkspaceContractFingerprint.MaxFiles) + { + captureContractFingerprint = false; + fingerprintTargets = null; + } + fingerprintTargets?.Add((target.IndexPath, generatedExtractionSuppressed)); + if (IsOutsideProjectRoot(relativePath)) + fingerprintComplete = 0; if (!generatedExtractionSuppressed) candidates.Add(target); } var extractedByCandidate = new List?[candidates.Count]; + var contractChecksums = captureContractFingerprint ? new string?[candidates.Count] : null; var artifactChecksums = symbolArtifactCache == null ? null : new string?[candidates.Count]; @@ -141,13 +159,15 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( if (MayContainCSharpWorkspaceReferenceTargets(content)) { + if (contractChecksums != null) + contractChecksums[candidateIndex] = CSharpWorkspaceContractFingerprint.HashSource(content); var extractionFilePath = symbolArtifactCache == null ? target.IndexPath : target.FilePath; var extractionProjectRoot = symbolArtifactCache == null ? null : indexer.ProjectRootForExtraction; - using var regexTimeouts = symbolArtifactCache == null + using var regexTimeouts = symbolArtifactCache == null && !captureContractFingerprint ? null : BoundedRegex.CaptureTimeouts( "csharp", @@ -172,9 +192,13 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( artifactSourceLengths[candidateIndex] = content.Length; if (regexTimeouts != null) { - artifactChecksums![candidateIndex] = checksum; - artifactHadRegexTimeouts![candidateIndex] = - regexTimeouts.HasTimeouts; + if (regexTimeouts.HasTimeouts) + Interlocked.Exchange(ref fingerprintComplete, 0); + if (artifactChecksums != null) + { + artifactChecksums[candidateIndex] = checksum; + artifactHadRegexTimeouts![candidateIndex] = regexTimeouts.HasTimeouts; + } } } } @@ -219,6 +243,10 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( out hadPendingMemberReadTargets, cancellationToken) : []; + // A fingerprint covers source inputs only. Retained out-of-scan database + // symbols are additional contract evidence and require conservative work. + if (captureContractFingerprint && symbols.Count > 0) + fingerprintComplete = 0; IReadOnlyList workspaceSymbols; CSharpWorkspaceSymbolEvidence pendingEvidence; if (symbolArtifactCache == null) @@ -308,7 +336,10 @@ internal static CSharpStaticInterfaceWorkspaceSymbols BuildWorkspaceSymbols( isSourceEvidenceComplete, incompletePaths, qualifiedPatternLookups, - requiresMemberReadReferenceRefresh); + requiresMemberReadReferenceRefresh, + ContractSourceFingerprint: captureContractFingerprint && isSourceEvidenceComplete && fingerprintComplete != 0 + ? CSharpWorkspaceContractFingerprint.Build(fingerprintTargets!, candidates, contractChecksums!, cancellationToken) + : null); } internal static CSharpWorkspaceSymbolEvidence AppendExtractedWorkspaceSymbols( @@ -1312,4 +1343,5 @@ internal sealed record CSharpStaticInterfaceWorkspaceSymbols( bool SourceContractEvidenceComplete = true, IReadOnlyList? IncompleteSourcePaths = null, ReferenceExtractor.CSharpQualifiedPatternLookups? QualifiedPatternLookups = null, - bool RequiresMemberReadReferenceRefresh = false); + bool RequiresMemberReadReferenceRefresh = false, + string? ContractSourceFingerprint = null); diff --git a/src/CodeIndex/Indexer/CSharpWorkspaceContractFingerprint.cs b/src/CodeIndex/Indexer/CSharpWorkspaceContractFingerprint.cs new file mode 100644 index 000000000..9a3e4e1c9 --- /dev/null +++ b/src/CodeIndex/Indexer/CSharpWorkspaceContractFingerprint.cs @@ -0,0 +1,65 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace CodeIndex.Indexer; + +internal static class CSharpWorkspaceContractFingerprint +{ + // This is an optimization proof, never a readiness stamp. Exceeding its budget + // leaves the existing conservative update path in charge. + internal const int MaxFiles = 50_000; + + internal static string HashSource(string content) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); + + internal static string? Build( + List<(string Path, bool Suppressed)> targets, + List candidates, + string?[] checksums, + CancellationToken cancellationToken) + { + if (targets.Count > MaxFiles) + return null; + + var candidateChecksums = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < candidates.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + candidateChecksums[candidates[i].IndexPath] = checksums[i]; + } + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + // Lookup builders receive ordered symbol lists. Bind the contributor order + // as well as the inventory rather than assuming ambiguous lookups commute. + Append(hash, "contributor_order"); + for (var i = 0; i < candidates.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (checksums[i] != null) + Append(hash, candidates[i].IndexPath); + } + Append(hash, "path_inventory"); + foreach (var target in targets.OrderBy(target => target.Path, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + Append(hash, target.Path); + Append(hash, target.Suppressed ? "suppressed" : "source"); + candidateChecksums.TryGetValue(target.Path, out var checksum); + // Only sources admitted by the existing static/enum/const candidate + // gate can contribute workspace lookup symbols. Still bind every path + // and suppression decision, so additions, renames and removals differ. + Append(hash, checksum ?? "no_workspace_symbols"); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } + + internal static void Append(IncrementalHash hash, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(length, bytes.Length); + hash.AppendData(length); + hash.AppendData(bytes); + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs index 5dd03bbee..b8b263ee8 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs @@ -1290,7 +1290,9 @@ await EmitProgressNotificationAsync( (DbContext.LastIndexRunReferenceExtractionCapHitsMetaKey, JsonSerializer.Serialize( referenceExtractionCapHits, StatusMetadataJsonContext.Default.ReferenceExtractionCapHitSummary)), - (DbContext.LastIndexRunRebuildReclaimMetaKey, null)); + (DbContext.LastIndexRunRebuildReclaimMetaKey, null), + (DbContext.LastIndexRunCSharpWorkspaceExpansionMetaKey, null), + (DbContext.CSharpWorkspaceContractBaselineMetaKey, null)); writer.MarkIndexCompleteness(writer.GetPersistedIndexOmissionReasons()); writer.ClearLastFailedIndexRunMetadata(); // Persist the current HEAD only after the run is fully successful (errors == 0). diff --git a/src/CodeIndex/Models/CSharpWorkspaceExpansion.cs b/src/CodeIndex/Models/CSharpWorkspaceExpansion.cs new file mode 100644 index 000000000..d4065ae8d --- /dev/null +++ b/src/CodeIndex/Models/CSharpWorkspaceExpansion.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace CodeIndex.Models; + +/// Fixed-size diagnostics for the C# scoped-update preflight. +public sealed class CSharpWorkspaceExpansion +{ + [JsonRequired] + public string Trigger { get; set; } = "no_csharp_targets"; + [JsonRequired] + public string Decision { get; set; } = "not_expanded"; + [JsonRequired] + public string Reason { get; set; } = "no_csharp_targets"; + [JsonPropertyName("original_target_count")] + [JsonRequired] + public int OriginalTargetCount { get; set; } + [JsonPropertyName("expanded_target_count")] + [JsonRequired] + public int ExpandedTargetCount { get; set; } + [JsonPropertyName("final_target_count")] + [JsonRequired] + public int FinalTargetCount { get; set; } + [JsonPropertyName("initial_prepass_ms")] + [JsonRequired] + public long InitialPrepassMs { get; set; } + [JsonPropertyName("workspace_scan_ms")] + [JsonRequired] + public long WorkspaceScanMs { get; set; } + [JsonPropertyName("workspace_prepass_ms")] + [JsonRequired] + public long WorkspacePrepassMs { get; set; } + [JsonPropertyName("workspace_prepass_file_count")] + [JsonRequired] + public int WorkspacePrepassFileCount { get; set; } + [JsonPropertyName("workspace_prepass_input_bytes")] + [JsonRequired] + public long WorkspacePrepassInputBytes { get; set; } + + internal bool IsValid() + => Trigger is "no_csharp_targets" or "csharp_targets" + or "persisted_static_interface_contracts" or "member_reference_targets" + or "source_static_interface_contracts" or "incomplete_contract_evidence" + or "configuration_changed" or "prior_partial_index" + && Decision is "not_expanded" or "expanded" or "narrowed" or "deferred" or "full_scan" + && Reason is "no_csharp_targets" or "no_workspace_contracts" + or "baseline_unavailable" or "contract_inputs_changed" or "contract_inputs_unchanged" + or "safety_checks_required" or "incomplete_workspace" + or "configuration_changed" or "prior_partial_index" + && OriginalTargetCount >= 0 && ExpandedTargetCount >= OriginalTargetCount + && FinalTargetCount >= 0 && FinalTargetCount <= ExpandedTargetCount + && InitialPrepassMs >= 0 && WorkspaceScanMs >= 0 && WorkspacePrepassMs >= 0 + && WorkspacePrepassFileCount >= 0 && WorkspacePrepassInputBytes >= 0; +} diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index db5431c0d..cd8d4d769 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2390,6 +2390,9 @@ public sealed class StatusPreparedCommandCache public sealed class StatusLastIndexRun { + [JsonPropertyName("csharp_workspace_expansion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CSharpWorkspaceExpansion? CSharpWorkspaceExpansion { get; set; } public string? Mode { get; set; } [JsonPropertyName("started_at")] public DateTime? StartedAt { get; set; } @@ -2538,6 +2541,7 @@ public sealed class StatusIndexFileError [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ReferenceExtractionCapHitSummary))] [JsonSerializable(typeof(StatusRebuildReclaim))] +[JsonSerializable(typeof(CSharpWorkspaceExpansion))] internal sealed partial class StatusMetadataJsonContext : JsonSerializerContext { } diff --git a/tests/CodeIndex.Tests/CSharpWorkspaceContractFingerprintTests.cs b/tests/CodeIndex.Tests/CSharpWorkspaceContractFingerprintTests.cs new file mode 100644 index 000000000..92fc760e7 --- /dev/null +++ b/tests/CodeIndex.Tests/CSharpWorkspaceContractFingerprintTests.cs @@ -0,0 +1,40 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +public class CSharpWorkspaceContractFingerprintTests +{ + [Fact] + public void Fingerprint_BindsContributionOrderInventoryAndBudget_Issue5347() + { + var targets = new List<(string Path, bool Suppressed)> + { + ("A.cs", false), ("B.cs", false), ("Consumer.cs", false), + }; + CSharpStaticInterfacePrepass.FileTarget Target(string path) + => new(path, path, path, path, "csharp"); + var candidates = new List + { Target("A.cs"), Target("B.cs"), Target("Consumer.cs") }; + string?[] checksums = ["source-a", "source-b", null]; + var baseline = CSharpWorkspaceContractFingerprint.Build(targets, candidates, checksums, default); + Assert.NotNull(baseline); + Assert.Equal(baseline, CSharpWorkspaceContractFingerprint.Build( + targets, [candidates[2], candidates[0], candidates[1]], [null, "source-a", "source-b"], default)); + Assert.NotEqual(baseline, CSharpWorkspaceContractFingerprint.Build( + targets, [candidates[1], candidates[0], candidates[2]], ["source-b", "source-a", null], default)); + Assert.NotEqual(baseline, CSharpWorkspaceContractFingerprint.Build( + targets, candidates, ["source-changed", "source-b", null], default)); + targets[2] = ("Renamed.cs", false); + Assert.NotEqual(baseline, CSharpWorkspaceContractFingerprint.Build(targets, candidates, checksums, default)); + targets[2] = ("Consumer.cs", true); + Assert.NotEqual(baseline, CSharpWorkspaceContractFingerprint.Build(targets, candidates, checksums, default)); + Assert.Throws(() => CSharpWorkspaceContractFingerprint.Build( + targets, candidates, checksums, new CancellationToken(canceled: true))); + + var bounded = Enumerable.Range(0, CSharpWorkspaceContractFingerprint.MaxFiles) + .Select(i => ($"File{i}.cs", false)).ToList(); + Assert.NotNull(CSharpWorkspaceContractFingerprint.Build(bounded, [], [], default)); + bounded.Add(("OverBudget.cs", false)); + Assert.Null(CSharpWorkspaceContractFingerprint.Build(bounded, [], [], default)); + } +} diff --git a/tests/CodeIndex.Tests/DbReaderStatusMetadataIssue5347Tests.cs b/tests/CodeIndex.Tests/DbReaderStatusMetadataIssue5347Tests.cs new file mode 100644 index 000000000..e93307c03 --- /dev/null +++ b/tests/CodeIndex.Tests/DbReaderStatusMetadataIssue5347Tests.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public partial class DbReaderTests +{ + [Fact] + public void GetStatus_ValidatesCSharpExpansionMetadata_Issue5347() + { + const string field = "last_index_run.csharp_workspace_expansion"; + const string key = DbContext.LastIndexRunCSharpWorkspaceExpansionMetaKey; + _writer.SetMeta(DbContext.LastIndexRunModeMetaKey, "update"); + var valid = new CSharpWorkspaceExpansion + { + Trigger = "member_reference_targets", + Decision = "expanded", + Reason = "baseline_unavailable", + OriginalTargetCount = 1, + ExpandedTargetCount = 3, + FinalTargetCount = 3, + }; + _writer.SetMeta(key, JsonSerializer.Serialize(valid, StatusMetadataJsonContext.Default.CSharpWorkspaceExpansion)); + Assert.Equal(3, _reader.GetStatus().LastIndexRun?.CSharpWorkspaceExpansion?.FinalTargetCount); + foreach (var bad in new[] + { + new CSharpWorkspaceExpansion { Trigger = "bad\ntrigger" }, + new CSharpWorkspaceExpansion { Decision = new string('x', 500) }, + new CSharpWorkspaceExpansion { OriginalTargetCount = 2, ExpandedTargetCount = 1 }, + new CSharpWorkspaceExpansion { FinalTargetCount = 1 }, + new CSharpWorkspaceExpansion { WorkspacePrepassInputBytes = -1 }, + }) + { + _writer.SetMeta(key, JsonSerializer.Serialize(bad, StatusMetadataJsonContext.Default.CSharpWorkspaceExpansion)); + var rejected = _reader.GetStatus(); + Assert.Null(rejected.LastIndexRun?.CSharpWorkspaceExpansion); + AssertMetadataDiagnostic(rejected, field, DbReader.StatusMetadataSemanticValidationFailedReason); + } + _writer.SetMeta(key, "{"); + AssertMetadataDiagnostic(_reader.GetStatus(), field, DbReader.StatusMetadataInvalidJsonReason); + _writer.SetMeta(key, new string(' ', StatusMetadataLimits.MaxRawUtf8Bytes + 1)); + AssertMetadataDiagnostic(_reader.GetStatus(), field, DbReader.StatusMetadataRawSizeExceededReason); + _writer.SetMeta(key, null); + Assert.Null(_reader.GetStatus().LastIndexRun?.CSharpWorkspaceExpansion); + } +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347ReviewTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347ReviewTests.cs new file mode 100644 index 000000000..1d660a9ae --- /dev/null +++ b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347ReviewTests.cs @@ -0,0 +1,159 @@ +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +public partial class IndexCommandRunnerTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Run_Update_ProjectMarkerChangeMatchesConservativeScope_Issue5347Review(bool duringExpansion) + { + var root = CreateExpansionProject5347(); + var priorHook = IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting; + try + { + Directory.CreateDirectory(Path.Combine(root, "src")); + var part = Path.Combine(root, "src", "Api.Part1.cs"); + File.WriteAllText(part, "public partial class Api { public int Run() => 1; }\n"); + File.WriteAllText(Path.Combine(root, "src", "Api.Part2.cs"), + "public partial class Api { public int Read() => Run(); }\n"); + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + Update5347(root, "src/Api.Part1.cs"); + + var controlPath = Path.Combine(root, ".cdidx", "control.db"); + using (var source = new DbContext(DbOpenIntent.WriteIndex, Path.Combine(root, ".cdidx", "codeindex.db"))) + using (var control = new DbContext(DbOpenIntent.WriteIndex, controlPath)) + { + source.Connection.BackupDatabase(control.Connection); + new DbWriter(control.Connection).SetMeta(DbContext.CSharpWorkspaceContractBaselineMetaKey, null); + } + + var markerPath = Path.Combine(root, "App.csproj"); + void AddMarker() => File.WriteAllText(markerPath, "\n"); + if (duringExpansion) + IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting = AddMarker; + else + AddMarker(); + File.WriteAllText(part, "public partial class Api { public int Run() => 2; }\n"); + string[] targets = duringExpansion ? ["src/Api.Part1.cs"] : ["src/Api.Part1.cs", "App.csproj"]; + var (exitCode, actual) = RunAndCaptureJson( + [root, "--files", .. targets, "--allow-partial", "--json"]); + Assert.True(exitCode == CommandExitCodes.Success, actual.ToString()); + Assert.Equal("expanded", actual.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.Equal("contract_inputs_changed", actual.GetProperty("csharp_workspace_expansion").GetProperty("reason").GetString()); + // Give the old-database control the same marker timing as the subject. + if (duringExpansion) + File.Delete(markerPath); + var (controlCode, conservative) = RunAndCaptureJson( + [root, "--db", controlPath, "--files", .. targets, "--allow-partial", "--json"]); + Assert.Equal(CommandExitCodes.Success, controlCode); + Assert.Equal(ReadSemanticRows5347(root, "control.db"), ReadSemanticRows5347(root)); + foreach (var flag in new[] { "index_complete", "reference_graph_complete", "hotspot_family_ready" }) + Assert.Equal(conservative.GetProperty(flag).GetBoolean(), actual.GetProperty(flag).GetBoolean()); + IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting = priorHook; + // The racing marker is outside the requested paths; compare C# data + // after a full scan also admits that newly created MSBuild file. + AssertFullParity5347(root, csharpOnly: duringExpansion); + } + finally + { + IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting = priorHook; + DeleteDirectory(root); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Run_Update_IncompleteProjectMarkerEvidencePreventsNarrowing_Issue5347Review(bool duringExpansion) + { + var root = CreateExpansionProject5347(); + var priorBudget = FileIndexer.ProjectMarkerFingerprintDirectoryBudgetForTesting; + var priorHook = IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting; + try + { + Directory.CreateDirectory(Path.Combine(root, "nested")); + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + Update5347(root, "Worker.cs"); + void ExhaustBudget() + { + FileIndexer.ProjectMarkerFingerprintDirectoryBudgetForTesting = 1; + Assert.False(new FileIndexer(root).GetProjectMarkerFingerprintResult("csharp").IsComplete); + } + if (duringExpansion) + IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting = ExhaustBudget; + else + ExhaustBudget(); + var (exitCode, json) = RunAndCaptureJson( + [root, "--files", "Worker.cs", "--allow-partial", "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + AssertExpansion5347(json, "expanded", "safety_checks_required", 1, 5, 5); + } + finally + { + IndexCommandRunner.UpdateCSharpExpansionScanStartingForTesting = priorHook; + FileIndexer.ProjectMarkerFingerprintDirectoryBudgetForTesting = priorBudget; + DeleteDirectory(root); + } + } + + [Fact] + public void Run_Update_CaseOnlyContractRenamePersistsPeakCounts_Issue5347Review() + { + var root = CreateTempProject(); + try + { + RunGit(root, "init"); + var oldPath = Path.Combine(root, "contract.cs"); + WriteParseableInterface(oldPath, hasStaticContract: true); + RunGit(root, "add", "."); + RunGit(root, "commit", "-m", "initial contract"); + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + RunGit(root, "mv", "contract.cs", "rename.tmp"); + RunGit(root, "mv", "rename.tmp", "Contract.cs"); + RunGit(root, "commit", "-m", "rename contract casing"); + var (exitCode, json) = RunAndCaptureJson([root, "--commits", "HEAD", "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + var expansion = json.GetProperty("csharp_workspace_expansion"); + Assert.Equal(2, expansion.GetProperty("original_target_count").GetInt32()); + Assert.Equal(2, expansion.GetProperty("expanded_target_count").GetInt32()); + var finalCount = expansion.GetProperty("final_target_count").GetInt32(); + Assert.InRange(finalCount, 1, 2); + if (File.Exists(oldPath)) + Assert.Equal(1, finalCount); + AssertExpansionPersisted5347(root, json); + Assert.False(IndexedFileExists(root, "contract.cs")); + Assert.True(IndexedFileExists(root, "Contract.cs")); + AssertFullParity5347(root); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public void Run_Update_FullScanFallbackRespectsQuiet_Issue5347Review() + { + var root = CreateExpansionProject5347(); + try + { + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + File.WriteAllText(Path.Combine(root, ".cdidxignore"), "scratch/\n"); + using var stdout = new StringWriter(); + var originalOut = Console.Out; + try + { + Console.SetOut(stdout); + Assert.Equal(CommandExitCodes.Success, + IndexCommandRunner.Run([root, "--files", ".cdidxignore", "--quiet"], _jsonOptions)); + } + finally { Console.SetOut(originalOut); } + Assert.DoesNotContain("C# update scope", stdout.ToString()); + using var db = new DbContext(DbOpenIntent.WriteIndex, Path.Combine(root, ".cdidx", "codeindex.db")); + using var reader = new DbReader(db.Connection); + Assert.Equal("full_scan", reader.GetStatus().LastIndexRun?.CSharpWorkspaceExpansion?.Decision); + } + finally { DeleteDirectory(root); } + } +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347Tests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347Tests.cs new file mode 100644 index 000000000..51668c843 --- /dev/null +++ b/tests/CodeIndex.Tests/IndexCommandRunnerIssue5347Tests.cs @@ -0,0 +1,274 @@ +using System.Globalization; +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public partial class IndexCommandRunnerTests +{ + [Fact] + public void Run_ChangedBetween_NarrowingPreservesVerifiedHead_Issue5347() + { + var root = CreateExpansionProject5347(); + try + { + RunGit(root, "init"); + RunGit(root, "add", "."); + RunGit(root, "commit", "-m", "initial contracts"); + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + File.WriteAllText(Path.Combine(root, "Worker.cs"), WorkerSource5347(3)); + RunGit(root, "add", "Worker.cs"); + RunGit(root, "commit", "-m", "prime contract proof with verified update"); + Assert.Equal(0, RunAndCaptureJson([root, "--commits", "HEAD", "--json"]).ExitCode); + RunGit(root, "branch", "before-independent"); + File.WriteAllText(Path.Combine(root, "Worker.cs"), WorkerSource5347(2)); + RunGit(root, "add", "Worker.cs"); + RunGit(root, "commit", "-m", "independent method edit"); + var (exitCode, json) = RunAndCaptureJson( + [root, "--changed-between", "before-independent", "HEAD", "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + AssertExpansion5347(json, "narrowed", "contract_inputs_unchanged", 1, 5, 1); + var (statusCode, status) = RunStatusAndCaptureJson( + ["--db", Path.Combine(root, ".cdidx", "codeindex.db"), "--check", "--json"]); + Assert.Equal(CommandExitCodes.Success, statusCode); + Assert.True(status.GetProperty("index_matches_workspace").GetBoolean()); + Assert.True(status.GetProperty("reference_graph_complete").GetBoolean()); + Assert.Equal(GitHelper.TryGetHeadCommit(root), status.GetProperty("workspace_verified_head_sha").GetString()); + AssertFullParity5347(root); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public void Run_Update_GeneratedPolicyAndFilteredEvidencePreventNarrowing_Issue5347() + { + var root = CreateExpansionProject5347(); + using var environment = EnvironmentVariableScope.Capture(IndexCommandRunner.GeneratedCodePatternsEnvironmentVariable); + try + { + Assert.Equal(0, RunAndCaptureJson([root, "--json"]).ExitCode); + Update5347(root, "Worker.cs"); + environment.Set(IndexCommandRunner.GeneratedCodePatternsEnvironmentVariable, "Money.cs"); + var generated = Update5347(root, "Worker.cs"); + AssertExpansion5347(generated, "expanded", "contract_inputs_changed", 1, 5, 5); + Assert.Equal(0, CountMoneyParseImplicitImplementationReferences(root)); + AssertFullParity5347(root); + + var (initialCode, _) = RunAndCaptureJson( + [root, "--exclude-symbol-kind", "field", "--allow-partial", "--json"]); + Assert.Equal(CommandExitCodes.Success, initialCode); + var (filteredCode, filtered) = RunAndCaptureJson( + [root, "--files", "Worker.cs", "--exclude-symbol-kind", "field", "--allow-partial", "--json"]); + Assert.Equal(CommandExitCodes.Success, filteredCode); + Assert.False(filtered.GetProperty("index_complete").GetBoolean()); + AssertExpansion5347(filtered, "expanded", "safety_checks_required", 1, 5, 5); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public void Run_Update_ContractIndependentEditMatchesFullIndex_Issue5347() + { + var projectRoot = CreateExpansionProject5347(); + try + { + Assert.Equal(0, RunAndCaptureJson([projectRoot, "--json"]).ExitCode); + var first = Update5347(projectRoot, "Worker.cs"); + AssertExpansion5347(first, "expanded", "baseline_unavailable", 1, 5, 5); + AssertExpansionPersisted5347(projectRoot, first); + + File.WriteAllText(Path.Combine(projectRoot, "Worker.cs"), WorkerSource5347(2)); + var independent = Update5347(projectRoot, "Worker.cs"); + AssertExpansion5347(independent, "narrowed", "contract_inputs_unchanged", 1, 5, 1); + Assert.Equal(1, independent.GetProperty("summary").GetProperty("updated").GetInt32()); + AssertExpansionPersisted5347(projectRoot, independent); + Assert.Equal(1, CountMoneyParseImplicitImplementationReferences(projectRoot)); + AssertFullParity5347(projectRoot); + + var noCSharp = Update5347(projectRoot, "notes.md"); + AssertExpansion5347(noCSharp, "not_expanded", "no_csharp_targets", 1, 1, 1); + AssertExpansionPersisted5347(projectRoot, noCSharp); + } + finally { DeleteDirectory(projectRoot); } + } + + [Fact] + public void Run_Update_ContractChangesRenamesRemovalsAndConfigurationStayConservative_Issue5347() + { + var projectRoot = CreateExpansionProject5347(); + try + { + Assert.Equal(0, RunAndCaptureJson([projectRoot, "--json"]).ExitCode); + Update5347(projectRoot, "Worker.cs"); + + File.WriteAllText(Path.Combine(projectRoot, "Settings.cs"), "public class Settings { public const int Other = 7; }\n"); + var members = Update5347(projectRoot, "Settings.cs"); + AssertExpansion5347(members, "expanded", "contract_inputs_changed", 1, 5, 5); + AssertFullParity5347(projectRoot); + + Update5347(projectRoot, "Worker.cs"); + WriteParseableInterface(Path.Combine(projectRoot, "IParseable.cs"), hasStaticContract: false); + var contracts = Update5347(projectRoot, "IParseable.cs"); + Assert.Equal("expanded", contracts.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.Equal(0, CountMoneyParseImplicitImplementationReferences(projectRoot)); + AssertFullParity5347(projectRoot); + + Update5347(projectRoot, "Settings.cs"); + File.Move(Path.Combine(projectRoot, "Settings.cs"), Path.Combine(projectRoot, "Renamed.cs")); + var rename = Update5347(projectRoot, "Settings.cs", "Renamed.cs"); + Assert.Equal("expanded", rename.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.False(IndexedFileExists(projectRoot, "Settings.cs")); + AssertFullParity5347(projectRoot); + + Update5347(projectRoot, "Renamed.cs"); + File.Delete(Path.Combine(projectRoot, "Renamed.cs")); + var removal = Update5347(projectRoot, "Renamed.cs"); + Assert.Equal("expanded", removal.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.False(IndexedFileExists(projectRoot, "Renamed.cs")); + AssertFullParity5347(projectRoot); + + File.WriteAllText(Path.Combine(projectRoot, ".cdidxignore"), "Consumer.cs\n"); + var config = Update5347(projectRoot, ".cdidxignore"); + Assert.Equal("full_scan", config.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.Equal("configuration_changed", config.GetProperty("csharp_workspace_expansion").GetProperty("reason").GetString()); + AssertExpansionPersisted5347(projectRoot, config); + Assert.False(IndexedFileExists(projectRoot, "Consumer.cs")); + AssertFullParity5347(projectRoot); + } + finally { DeleteDirectory(projectRoot); } + } + + [Fact] + public void Run_Update_NarrowingRetainsWorkspaceRaceBarrierAndCancellation_Issue5347() + { + var projectRoot = CreateExpansionProject5347(); + var previousBarrier = IndexCommandRunner.UpdateScanInputSnapshotBarrierForTesting; + var previousPrepass = IndexCommandRunner.UpdateCSharpPrepassForTesting; + try + { + Assert.Equal(0, RunAndCaptureJson([projectRoot, "--json"]).ExitCode); + Update5347(projectRoot, "Worker.cs"); + var before = ReadSemanticRows5347(projectRoot); + IndexCommandRunner.UpdateScanInputSnapshotBarrierForTesting = phase => + { + if (phase == "before_write") + File.WriteAllText(Path.Combine(projectRoot, ".cdidxignore"), "Settings.cs\n"); + }; + var (exitCode, raced) = RunAndCaptureJson([projectRoot, "--files", "Worker.cs", "--json"]); + Assert.Equal(CommandExitCodes.PartialResult, exitCode); + Assert.Equal("deferred", raced.GetProperty("csharp_workspace_expansion").GetProperty("decision").GetString()); + Assert.Equal(before, ReadSemanticRows5347(projectRoot)); + IndexCommandRunner.UpdateScanInputSnapshotBarrierForTesting = previousBarrier; + File.Delete(Path.Combine(projectRoot, ".cdidxignore")); + + using var cancellation = new CancellationTokenSource(); + IndexCommandRunner.UpdateCSharpPrepassForTesting = cancellation.Cancel; + var cancelled = IndexCommandRunner.Run([projectRoot, "--files", "Worker.cs", "--json", "--quiet"], _jsonOptions, cancellation); + Assert.NotEqual(CommandExitCodes.Success, cancelled); + Assert.Equal(before, ReadSemanticRows5347(projectRoot)); + } + finally + { + IndexCommandRunner.UpdateScanInputSnapshotBarrierForTesting = previousBarrier; + IndexCommandRunner.UpdateCSharpPrepassForTesting = previousPrepass; + DeleteDirectory(projectRoot); + } + } + + private static string WorkerSource5347(int value) + => $"public class Worker\n{{\n public int Read()\n {{\n return {value};\n }}\n}}\n"; + + private static string CreateExpansionProject5347() + { + var root = CreateTempProject(); + WriteParseableInterface(Path.Combine(root, "IParseable.cs"), hasStaticContract: true); + File.WriteAllText(Path.Combine(root, "Money.cs"), "public readonly struct Money : IParseable\n{\n public static Money Parse(string s) => new();\n}\n"); + File.WriteAllText(Path.Combine(root, "Settings.cs"), "public class Settings { public const int Limit = 7; }\n"); + File.WriteAllText(Path.Combine(root, "Consumer.cs"), "public class Consumer { public int Read() => Settings.Limit; }\n"); + File.WriteAllText(Path.Combine(root, "Worker.cs"), WorkerSource5347(1)); + File.WriteAllText(Path.Combine(root, "notes.md"), "# Notes\n"); + return root; + } + + private JsonElement Update5347(string root, params string[] paths) + { + var (exitCode, json) = RunAndCaptureJson([root, "--files", .. paths, "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(json.GetProperty("index_complete").GetBoolean()); + Assert.True(json.GetProperty("reference_graph_complete").GetBoolean()); + return json; + } + + private static void AssertExpansion5347(JsonElement json, string decision, string reason, int original, int expanded, int final) + { + var value = json.GetProperty("csharp_workspace_expansion"); + Assert.True(decision == value.GetProperty("decision").GetString(), value.ToString()); + Assert.Equal(reason, value.GetProperty("reason").GetString()); + Assert.Equal(original, value.GetProperty("original_target_count").GetInt32()); + Assert.Equal(expanded, value.GetProperty("expanded_target_count").GetInt32()); + Assert.Equal(final, value.GetProperty("final_target_count").GetInt32()); + foreach (var phase in new[] { "initial_prepass_ms", "workspace_scan_ms", "workspace_prepass_ms" }) + Assert.True(value.GetProperty(phase).GetInt64() >= 0); + } + + private void AssertExpansionPersisted5347(string root, JsonElement immediate) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, Path.Combine(root, ".cdidx", "codeindex.db")); + using var reader = new DbReader(db.Connection); + var status = reader.GetStatus(); + Assert.NotNull(status.LastIndexRun?.CSharpWorkspaceExpansion); + Assert.Equal(immediate.GetProperty("csharp_workspace_expansion").ToString(), + JsonSerializer.SerializeToElement(status.LastIndexRun.CSharpWorkspaceExpansion, _jsonOptions).ToString()); + Assert.True(status.IndexComplete); + Assert.True(status.ReferenceGraphComplete); + } + + private void AssertFullParity5347(string root, bool csharpOnly = false) + { + var scoped = ReadSemanticRows5347(root, csharpOnly: csharpOnly); + var (exitCode, full) = RunAndCaptureJson([root, "--rebuild", "--yes", "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(full.GetProperty("index_complete").GetBoolean()); + Assert.True(full.GetProperty("reference_graph_complete").GetBoolean()); + Assert.Equal(scoped, ReadSemanticRows5347(root, csharpOnly: csharpOnly)); + } + + private static string[] ReadSemanticRows5347(string root, string databaseFileName = "codeindex.db", bool csharpOnly = false) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, Path.Combine(root, ".cdidx", databaseFileName)); + var rows = new List(); + var filter = csharpOnly ? " WHERE f.lang = 'csharp'" : ""; + foreach (var sql in new[] + { + "SELECT path, lang, checksum FROM files f" + filter, + "SELECT f.path, s.* FROM symbols s JOIN files f ON f.id = s.file_id" + filter, + """ + SELECT f.path, r.*, sf.path AS source_file, ss.name AS source_name, + tf.path AS target_file, ts.name AS target_name + FROM symbol_references r JOIN files f ON f.id = r.file_id + LEFT JOIN symbols ss ON ss.id = r.source_symbol_id LEFT JOIN files sf ON sf.id = ss.file_id + LEFT JOIN symbols ts ON ts.id = r.target_symbol_id LEFT JOIN files tf ON tf.id = ts.file_id + """ + filter, + }) + { + using var command = db.Connection.CreateCommand(); + command.CommandText = sql; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var fields = new List(); + for (var i = 0; i < reader.FieldCount; i++) + { + if (reader.GetName(i) is "id" or "file_id" or "reference_line_id" or "source_symbol_id" or "target_symbol_id") + continue; + fields.Add(reader.IsDBNull(i) ? null : Convert.ToString(reader.GetValue(i), CultureInfo.InvariantCulture)); + } + rows.Add(JsonSerializer.Serialize(fields)); + } + } + return rows.Order(StringComparer.Ordinal).ToArray(); + } +}