diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 020ed61e9..e57973d73 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -33,7 +33,7 @@ Development contracts: |---|---| | Formatting and warnings | CI enforces repository formatting with `.editorconfig` and treats compiler warnings as errors through `Directory.Build.props`, so local changes should pass the format check before opening a PR. Existing trim-analysis warnings are explicitly listed in `WarningsNotAsErrors` until they are fixed without blocking ordinary compiler-warning enforcement, and ILLink keeps reporting trim warnings without failing trimmed publish smoke tests. | | CLI help | `cdidx --help` stays brief, `cdidx --help-all` prints the full command/flag/example reference, `cdidx --help-flags` prints only shared flag tables, and `cdidx --help` prints one command's usage line. Keep new commands visible in the brief summary only when they are a primary user workflow; every command must remain present in the full help and command-specific usage table. | -| `index --dry-run` mutation estimates | Dry-run remains strictly non-mutating: it does not create or lock the database and does not alter sources or index artifacts. It uses read-only stat/checksum data together with the production cap-issue, symbol-filter, extractor-version, graph-contract, forced extractor/config refresh, hotspot-marker trust, and C# workspace reuse gates to project unchanged skips; projects updates, content-policy skips, deletes, purges, and symbol/reference cap hits; and performs the normal chunk/symbol/reference/content-diagnostic extraction for at most 100 planned update files. Scoped `--files`, `--commits`, and `--changed-between` previews run a read-only C# preflight that reuses production static-interface and qualified member-read detection and expands candidate paths before counts, limits, and samples are finalized. `projection_authoritative`, `projection_unavailable_reasons`, and the C# workspace expansion status/reason distinguish exact projections from lower bounds when a candidate cap, scan failure, snapshot gap, or unavailable preflight prevents safe expansion. `estimated_table_mutations` counts projected delete-and-insert row operations, not final table cardinality. Its nullable values must stay synchronized with `estimated_table_mutation_details`: `source` identifies filesystem planning or parse-only plus index-snapshot input, `confidence` is `exact`, `estimate`, or `unknown`, and stable `unknown_reasons` cover candidate truncation, parse-estimate truncation/failure, C# workspace expansion, unreadable index snapshots, and partial-index table absence. A numeric zero is reserved for measured no work and remains qualified by its confidence. Parse-only estimates intentionally omit post-extraction hook mutations and post-preflight cross-file child-row materialization, so child-table metrics become explicitly unknown when C# workspace expansion applies instead of retaining incomplete parse totals. | +| `index --dry-run` mutation estimates | Dry-run remains strictly non-mutating: it does not create or lock the database and does not alter sources or index artifacts. It uses read-only stat/checksum data together with the production cap-issue, symbol-filter, extractor-version, graph-contract, forced extractor/config refresh, hotspot-marker trust, and C# workspace reuse gates to project unchanged skips; projects updates, content-policy skips, deletes, purges, and symbol/reference cap hits; and performs the normal chunk/symbol/reference/content-diagnostic extraction for at most 100 planned update files. Scoped `--files`, `--commits`, and `--changed-between` previews run a read-only C# preflight that reuses production static-interface and qualified member-read detection and expands candidate paths before counts, limits, and samples are finalized. `projection_authoritative`, `projection_unavailable_reasons`, and the C# workspace expansion status/reason distinguish exact projections from lower bounds when a candidate cap, scan failure, snapshot gap, or unavailable preflight prevents safe expansion. `table_row_estimates` reports `rows_deleted`, `rows_inserted_or_upserted`, total `row_operations`, `projected_final_rows`, and `projected_row_delta` per table. Each dimension owns an independent nullable `value`, `source`, `confidence` (`exact`, `estimate`, or `unknown`), and stable `unknown_reasons`; candidate truncation marks every dimension unknown, parse-estimate truncation/failure and C# workspace expansion affect insertion-derived dimensions without discarding known snapshot deletes, TypeScript changes that can rebuild declaration-merging augmentation references mark all `symbol_references` dimensions unknown with `typescript_augmentation_rebuild_required`, and unreadable snapshots or missing partial-index tables affect only snapshot-derived dimensions and their dependents. Arithmetic is checked, and overflow or a negative projected cardinality becomes explicit unknown rather than wrapping. `estimated_table_mutations` and `estimated_table_mutation_details` remain deprecated compatibility aliases for total `row_operations`; `estimated_table_mutations_semantics`, `estimated_table_mutations_deprecated`, and `estimated_table_mutations_replacement` make that contract and the `table_row_estimates..row_operations` migration path explicit, with removal reserved for a future major release. A numeric zero is reserved for measured no work and remains qualified by its confidence. Parse-only estimates intentionally omit post-extraction hook mutations and post-preflight cross-file child-row materialization, so child-table insertion, operation, final-count, and delta dimensions become explicitly unknown when C# workspace expansion applies instead of retaining incomplete parse totals. | | `cdidx validate` | This is the user-facing integrity scan for indexed content issues such as replacement characters, BOMs, NUL bytes, mixed line endings, UTF-16 BOMs, and likely non-UTF8 content. Keep its CLI usage, README entry, and help summary in sync when adding validation issue kinds or filters. | | `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid with `--json --env-inventory=full` or `--integrations --json`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | | Exception diagnostics | User-facing CLI, JSON, MCP, file-issue, and local diagnostic output must not echo raw `ex.Message` directly. Route exception prose through `CommandErrorWriter.FormatSanitizedExceptionMessage`, `DiagnosticSanitizer.ForMessage`, or an existing bounded `DiagnosticRedactor` helper, and use stable error codes/categories when the message is not needed for recovery. Intentional broad catches should match the `risky-code/broad-exception-catch` taxonomy and normalize to bounded diagnostics, private best-effort suppression, or a documented fallback. | @@ -4334,7 +4334,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま |---|---| | formatting と warning | CI は `.editorconfig` による repository formatting を強制し、`Directory.Build.props` により compiler warning を error として扱います。ローカル変更は PR 前に formatting check を通してください。既存の trim 解析警告は、通常の警告エラー化を止めずに修正を進められるよう `WarningsNotAsErrors` に明示列挙されています。ILLink は trimmed publish の smoke test を失敗させずに trim warning を報告し続けます。 | | CLI help | `cdidx --help` は短い概要、`cdidx --help-all` は全コマンド・flag・例の一覧、`cdidx --help-flags` は共有 flag table のみ、`cdidx --help` は 1 コマンドの usage line を出します。新しいコマンドは主要な user workflow である場合だけ簡易概要に載せ、full help とコマンド固有の usage table には必ず載せてください。 | -| `index --dry-run` の mutation 推定 | dry-run は厳密に非変更のままにし、database の作成・lock や source / index artifact の変更を行いません。read-only な stat / checksum と production の cap issue、symbol filter、extractor version、graph contract、extractor / config の強制 refresh、hotspot marker の trust、C# workspace の再利用条件を組み合わせて未変更 skip を判定し、update、内容に対する policy skip、delete、purge、symbol / reference 上限到達を予測し、update 予定 file のうち最大100件に通常の chunk / symbol / reference / content diagnostic 抽出を適用します。scoped な `--files`、`--commits`、`--changed-between` preview は read-only C# preflight を実行し、production と同じ static-interface / 修飾 member-read 検出を再利用して、件数・上限・sample を確定する前に candidate path を展開します。`projection_authoritative`、`projection_unavailable_reasons`、C# workspace 展開の status / reason は、candidate cap、scan failure、snapshot 不足、利用不能な preflight によって安全な展開ができない場合の lower bound と正確な projection を区別します。`estimated_table_mutations` は最終 table row 数ではなく、delete と insert の予測 row operation 数です。nullable な値は `estimated_table_mutation_details` と常に同期させます。`source` は filesystem plan または parse-only と index snapshot の入力元、`confidence` は `exact` / `estimate` / `unknown`、安定した `unknown_reasons` は candidate の切り詰め、parse 推定の切り詰め・失敗、C# workspace の展開、index snapshot の読み取り不能、partial index の table 不足を表します。数値のゼロは、処理がないと計測できた場合だけに使い、その信頼度も併記します。parse-only 推定では extraction 後の hook mutation と preflight 後の cross-file child-row materialization を意図的に省くため、C# workspace 展開が適用された場合は不完全な parse 合計を残さず child-table metric を明示的な unknown にします。 | +| `index --dry-run` の mutation 推定 | dry-run は厳密に非変更のままにし、database の作成・lock や source / index artifact の変更を行いません。read-only な stat / checksum と production の cap issue、symbol filter、extractor version、graph contract、extractor / config の強制 refresh、hotspot marker の trust、C# workspace の再利用条件を組み合わせて未変更 skip を判定し、update、内容に対する policy skip、delete、purge、symbol / reference 上限到達を予測し、update 予定 file のうち最大100件に通常の chunk / symbol / reference / content diagnostic 抽出を適用します。scoped な `--files`、`--commits`、`--changed-between` preview は read-only C# preflight を実行し、production と同じ static-interface / 修飾 member-read 検出を再利用して、件数・上限・sample を確定する前に candidate path を展開します。`projection_authoritative`、`projection_unavailable_reasons`、C# workspace 展開の status / reason は、candidate cap、scan failure、snapshot 不足、利用不能な preflight によって安全な展開ができない場合の lower bound と正確な projection を区別します。`table_row_estimates` は table ごとに `rows_deleted`、`rows_inserted_or_upserted`、合計 `row_operations`、`projected_final_rows`、`projected_row_delta` を返します。各 dimension は独立した nullable な `value`、`source`、`confidence`(`exact` / `estimate` / `unknown`)、安定した `unknown_reasons` を所有します。candidate の切り詰めは全 dimension を unknown にし、parse 推定の切り詰め・失敗と C# workspace 展開は既知の snapshot delete を捨てずに insert 由来 dimension だけへ影響します。宣言マージの augmentation reference を再構築し得る TypeScript の変更では、`symbol_references` の全 dimension を `typescript_augmentation_rebuild_required` 理由の unknown とし、snapshot の読み取り不能や partial index の table 不足は snapshot 由来 dimension とその依存先だけへ影響します。算術は checked とし、overflow や負の予測 cardinality は wrap せず明示的な unknown にします。`estimated_table_mutations` と `estimated_table_mutation_details` は合計 `row_operations` の非推奨 compatibility alias として維持します。`estimated_table_mutations_semantics`、`estimated_table_mutations_deprecated`、`estimated_table_mutations_replacement` が契約と `table_row_estimates.
.row_operations` への移行先を明示し、削除は将来の major release に限定します。数値のゼロは、処理がないと計測できた場合だけに使い、その信頼度も併記します。parse-only 推定では extraction 後の hook mutation と preflight 後の cross-file child-row materialization を意図的に省くため、C# workspace 展開が適用された場合は不完全な parse 合計を残さず child-table の insert、operation、最終件数、delta dimension を明示的な unknown にします。 | | `cdidx validate` | replacement character、BOM、NUL byte、混在改行、UTF-16 BOM、非 UTF-8 らしい内容など、indexed content の問題を user-facing に検査する integrity scan です。validation issue の種別や filter を追加する場合は、CLI usage、README entry、help summary を同期してください。 | | `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` または `--integrations --json` と組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | | 例外診断 | user-facing な CLI / JSON / MCP / file issue / local diagnostic output では raw `ex.Message` を直接 echo しないでください。例外の prose は `CommandErrorWriter.FormatSanitizedExceptionMessage`、`DiagnosticSanitizer.ForMessage`、または既存の bounded な `DiagnosticRedactor` helper を通し、回復に message が不要な場合は安定した error code/category を使ってください。意図的に残す broad catch は `risky-code/broad-exception-catch` taxonomy に沿い、bounded diagnostic、private な best-effort suppression、または documented fallback に正規化してください。 | diff --git a/README.md b/README.md index 1dfbb90db..0ebf27bab 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,21 @@ table-mutation estimates without changing the source tree or index. See the [User Guide indexing workflow](USER_GUIDE.md#index-a-project) for usage and the [Developer Guide](DEVELOPER_GUIDE.md#build--test) for implementation limits. +`table_row_estimates` separates, for every affected table, `rows_deleted`, +`rows_inserted_or_upserted`, total `row_operations`, `projected_final_rows`, and +`projected_row_delta`. Each dimension has its own nullable `value`, `source`, +`confidence`, and `unknown_reasons`, so a known delete count remains usable when +parse-only insertion estimates are unavailable. TypeScript changes that can +rebuild declaration-merging augmentation references mark every affected +`symbol_references` dimension unknown with +`typescript_augmentation_rebuild_required`, because rows owned by otherwise +unchanged files can also be replaced. The legacy +`estimated_table_mutations` / `estimated_table_mutation_details` fields remain +as deprecated total-row-operation aliases for compatibility; the accompanying +semantics and replacement fields point clients to +`table_row_estimates.
.row_operations`, and removal is reserved for a +future major release. + Scoped `--files`, `--commits`, and `--changed-between` previews include the same read-only C# workspace expansion that execution requires for static interface contracts and qualified member-read refreshes. JSON reports @@ -426,6 +441,19 @@ file action と上限付き table mutation estimate を preview します。使 [プロジェクトをインデックス](USER_GUIDE.md#プロジェクトをインデックス)、実装上の制限は [開発者ガイド](DEVELOPER_GUIDE.md#ビルドテスト)を参照してください。 +`table_row_estimates` は対象 table ごとに `rows_deleted`、 +`rows_inserted_or_upserted`、合計 `row_operations`、`projected_final_rows`、 +`projected_row_delta` を分離します。各 dimension は独自の nullable な `value`、 +`source`、`confidence`、`unknown_reasons` を持つため、parse-only の insert 推定が +利用不能でも既知の delete 件数は利用できます。宣言マージの augmentation reference を +再構築し得る TypeScript の変更では、未変更 file が所有する row も置換される可能性があるため、 +影響する `symbol_references` の全 dimension を +`typescript_augmentation_rebuild_required` 理由の unknown とします。従来の +`estimated_table_mutations` / `estimated_table_mutation_details` は互換性のため +非推奨の総 row-operation alias として維持され、semantics / replacement field が +`table_row_estimates.
.row_operations` への移行先を示します。削除は将来の +major release だけで行います。 + `--files`、`--commits`、`--changed-between` の scoped preview では、static interface contract と修飾 member-read refresh のために実行時に必要となるものと同じ read-only C# workspace 展開を反映します。JSON は `projection_authoritative`、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index c8ade8775..386a835cc 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5236 dry-run row-estimate coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep new-database, unchanged rebuild, changed update followed by execution, unchanged skip, delete, checksum-rename purge, parse-unavailable transitions, and cross-file TypeScript augmentation rebuilds together. Across both target frameworks, assert independent delete, insert/upsert, total-operation, projected-final, and delta values/provenance, legacy-alias semantics and migration metadata, human/JSON naming parity, conservative `symbol_references` unknowns for augmentation rebuilds, and database/source non-mutation. - Issue #5233 projected-array coverage belongs in `JsonEnvelopeWrapperIssue4585Tests.cs`. Keep `search`, `symbols`, and `files` on the shared projection path, preserving bare-array roots, empty arrays, row and requested-field order, aliases, search limit aliases, exact UTF-8 byte-limit trimming, and complete versioned errors. The same focused fixture must retain unprojected array and NDJSON controls, object-mode precedence for compact/summary output across explicit stream selectors, envelope metadata shapes, typed unknown-field errors, rightmost repeated JSON selectors, and fail-fast structured errors for projected explicit NDJSON, supported or unsupported incompatible non-JSON formats, search aggregations, metadata-dependent selectors, stream-only controls, or diagnostics. Cursor-focused fixtures must opt into `--json-envelope` instead of relying on `--fields` to override an explicit array or stream selector. - Issue #5232 `goto` ambiguity coverage belongs in `QueryCommandRunnerIssue5232Tests.cs`. Keep zero, one, and many-match controls together; explicit-JSON and implicit human ambiguity modes must preserve stream placement, stable command/error/exit identity, authoritative totals and omitted counts, structured narrowing guidance, sanitized candidates, and both the 20-candidate and 16 KiB candidate budgets across both target frameworks. - Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, `WorkspaceCommandRunnerTests.cs`, and `WorkspaceMetadataEnricherTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, changed HEAD/branch provenance, `status.showUntrackedFiles=no`, and `skip-worktree` / `assume-unchanged` index flags, including already-indexed untracked paths whose authoritative content check matches and tracked-path output beyond the bounded diagnostic capture size. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, `head_freshness`, workspace member health/exit status, and MCP classification/check parity or an explicit conservative `unknown` across both target frameworks. The Git-index visibility scan must remain gated behind every ordinary-status trust precondition and must not run for authoritative check mode. @@ -1182,6 +1183,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5236 の dry-run row estimate coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。新規 database、未変更 rebuild、変更 update と後続 execution、未変更 skip、delete、checksum rename purge、parse 利用不能の transition、cross-file TypeScript augmentation rebuild を同じまとまりに維持してください。両 target framework で、独立した delete、insert/upsert、総 operation、予測最終件数、delta の値 / provenance、legacy alias の semantics と移行 metadata、human / JSON の命名 parity、augmentation rebuild 時の保守的な `symbol_references` unknown、checked arithmetic behavior、database / source の非変更を assertion します。 - Issue #5233 の projected array coverage は `JsonEnvelopeWrapperIssue4585Tests.cs` が担当します。`search`、`symbols`、`files` を共有 projection path に保ち、bare array root、空配列、row と指定 field の順序、alias、search の limit alias、UTF-8 byte 上限の正確な末尾切り詰め、完全な version 付き error を維持してください。同じ focused fixture で、非投影の array / NDJSON control、明示 stream selector に対する compact / summary の object mode 優先、envelope の metadata shape、未知 field の型付き error、繰り返した JSON selector の右端優先、および投影付きの明示 NDJSON、対応済み・未対応の非互換な非 JSON format、search aggregation、metadata 依存 selector、stream 専用 control、diagnostic に対する部分出力前の構造化 error も固定します。cursor を対象にする fixture は、`--fields` による明示 array / stream selector の上書きに依存せず、`--json-envelope` を指定してください。 - Issue #5232 の `goto` ambiguity coverage は `QueryCommandRunnerIssue5232Tests.cs` が担当します。0件・1件・複数件の control を同じ fixture に維持してください。両 target framework で、明示的 JSON と暗黙の human ambiguity mode の stream 配置、安定した command / error / exit identity、authoritative な総数と省略数、構造化 narrowing guidance、sanitization 済み candidate、20件と16 KiBの両 candidate budget を検証します。 - Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs`、`WorkspaceMetadataEnricherTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag、authoritative な content check が一致する index 済み未追跡 path、および bounded diagnostic capture size を超える tracked-path 出力について、決定的な境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、`head_freshness`、workspace member health / 終了 status、MCP の分類と check が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。Git index visibility scan は通常 status の全 trust precondition が成立した場合だけ実行し、authoritative check mode では実行してはいけません。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d68979c6c..c9eeadb90 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2426,7 +2426,7 @@ same source location. | `--duplicate-confidence ` / `--duplicate-threshold ` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Tune duplicate-preflight matching. `low`, `medium`, and `high` map to score thresholds of 0.35, 0.45, and 0.7; `--duplicate-threshold` accepts an explicit 0.0-1.0 score and reports `confidence: "custom"` in the JSON summary. | | `--issue-title ` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | -| `--dry-run` | `index` | Scan files and report what would change without writing to the database; JSON includes the resolved symbol-kind filter and filtered symbol mutation estimate | +| `--dry-run` | `index` | Scan files and report what would change without writing to the database; human and JSON output separate per-table deletes, inserts/upserts, total row operations, projected final rows, and projected delta, with independent confidence and unknown reasons. Legacy mutation fields remain deprecated total-operation aliases. | | `--limit <n>` / `--max-results <n>` | Query result commands except `suggestions`; `--max-results` is `search` only | Max results (default: 20, max: 10000; `map` uses it per section) | | `--lang <lang>` | Query commands | Filter by language (case-insensitive; `--lang Python` is treated as `--lang python`). Common aliases such as `c#`, `cs`, `kt`, and `kts` are also accepted. Unknown values emit an `Available: <languages>` hint on zero-result responses in human-readable output. | | `--visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Include only symbols with the requested visibility values: `public`, `protected`, `internal`, `private`. `public` matches stored exported aliases such as `pub`, `open`, and `export`; `private` also matches `fileprivate`. | @@ -2881,7 +2881,7 @@ and duplicate targets. cdidx scans your project directory, applies the built-in skip lists plus user `.gitignore` / `.cdidxignore` rules, skips Windows Hidden/System paths before language detection, splits each remaining source file into overlapping chunks, and stores everything in a SQLite database with FTS5 full-text search. In each directory, `.gitignore` is loaded before `.cdidxignore`; later rules are additive, so a `!` pattern in `.cdidxignore` can re-include a path ignored earlier by `.gitignore` in the same directory scope. Incremental mode (default) first purges database entries for files that no longer exist on disk, then checks each file's last-modified timestamp against the database — only files whose timestamp exactly matches are skipped, and any difference (newer or older) triggers re-indexing. Newly appeared files are indexed as new entries. The same path filter is reused for scoped `--files` / `--commits` refreshes, commit-based refreshes automatically switch to a full scan when ignore files changed, and Git-managed workspaces follow the repository's `core.ignorecase` setting when evaluating ignore rules. This means re-indexing after a branch switch only processes the files that actually differ unless ignore rules themselves changed. -At index time, `--include-symbol-kind` keeps only matching symbol kinds and `--exclude-symbol-kind` drops matching symbol kinds before rows are written to `symbols`. Values are comma-separated and case-insensitive. If both filters are present, include is applied first and exclude wins for overlapping kinds. The normalized policy is persisted for the current generation, and each file records its dropped-symbol count. Index, `status --json`, workspace status, and MCP responses expose `symbol_kind_filter_provenance_available`, `symbol_kind_filter`, and the generation-wide `symbols_dropped_by_kind_filter`. Any active policy makes `index_complete=false`, `graph_data_current=false`, and `reference_graph_complete=false` with `symbol_kind_filter_coverage_limited`, even when no symbol happened to be dropped, because negative symbol and graph results do not describe full repository coverage. A legacy DB without policy provenance stays readable but uses the conservative `symbol_kind_filter_provenance_unavailable` fallback; scoped updates are rejected until a whole-workspace refresh stamps current provenance. Row-only JSON array and NDJSON symbol queries emit the same negative-result authority warning on stderr, while file, text-search, and validation counts remain authoritative because symbol-kind filtering does not affect those datasets. Dry-run JSON applies the same policy to its DB-backed `estimated_table_mutations.symbols` estimate; reference estimates remain unchanged because symbol-kind filters do not filter reference rows. +At index time, `--include-symbol-kind` keeps only matching symbol kinds and `--exclude-symbol-kind` drops matching symbol kinds before rows are written to `symbols`. Values are comma-separated and case-insensitive. If both filters are present, include is applied first and exclude wins for overlapping kinds. The normalized policy is persisted for the current generation, and each file records its dropped-symbol count. Index, `status --json`, workspace status, and MCP responses expose `symbol_kind_filter_provenance_available`, `symbol_kind_filter`, and the generation-wide `symbols_dropped_by_kind_filter`. Any active policy makes `index_complete=false`, `graph_data_current=false`, and `reference_graph_complete=false` with `symbol_kind_filter_coverage_limited`, even when no symbol happened to be dropped, because negative symbol and graph results do not describe full repository coverage. A legacy DB without policy provenance stays readable but uses the conservative `symbol_kind_filter_provenance_unavailable` fallback; scoped updates are rejected until a whole-workspace refresh stamps current provenance. Row-only JSON array and NDJSON symbol queries emit the same negative-result authority warning on stderr, while file, text-search, and validation counts remain authoritative because symbol-kind filtering does not affect those datasets. Dry-run JSON applies the same policy to `table_row_estimates.symbols.rows_inserted_or_upserted` and its dependent operation/final-state estimates. The deprecated `estimated_table_mutations.symbols` alias reports the same total row-operation value; reference estimates remain unchanged because symbol-kind filters do not filter reference rows. ### Incremental update reliability @@ -6144,7 +6144,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--duplicate-confidence <low\|medium\|high>` / `--duplicate-threshold <score>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | duplicate preflight の一致しきい値を調整します。`low`、`medium`、`high` は score threshold 0.35、0.45、0.7 に対応します。`--duplicate-threshold` は明示的な 0.0-1.0 の score を受け取り、JSON summary では `confidence: "custom"` を出力します。 | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | -| `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告。JSON には解決済み symbol-kind filter とフィルター適用後の symbol mutation 推計を含む | +| `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告。human / JSON は table ごとの delete、insert/upsert、総 row operation、予測最終 row 数、予測 delta を独立した confidence / unknown reason 付きで分離する。従来の mutation field は非推奨の総 operation alias として維持。 | | `--limit <n>` / `--max-results <n>` | `suggestions` 以外のクエリ結果コマンド。`--max-results` は `search` のみ | 最大結果数(デフォルト: 20、最大: 10000。`map` では各セクションごとの件数) | | `--visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | `public`, `protected`, `internal`, `private` の可視性でシンボルを絞り込む。`public` は `pub`、`open`、`export` などの保存済み exported alias にも一致し、`private` は `fileprivate` にも一致 | | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | @@ -6593,7 +6593,7 @@ directory link を full scanner と同じ depth-first 順で辿り、解決済 cdidxはプロジェクトディレクトリを走査し、組み込みのスキップ対象とユーザーの `.gitignore` / `.cdidxignore` を適用し、Windows の Hidden/System パスを言語検出前にスキップしたうえで、各ソースファイルを重複を持つチャンクに分割し、FTS5全文検索付きのSQLiteデータベースに格納します。同じディレクトリでは `.gitignore` を先に読み、`.cdidxignore` を後から読むため、後の `.cdidxignore` ルールは加算的に適用され、`!` パターンで同じディレクトリスコープの `.gitignore` 除外を再包含できます。インクリメンタルモード(デフォルト)では各ファイルの最終更新タイムスタンプをDB内の値と比較し、完全一致するファイルのみスキップします。タイムスタンプが異なれば(新しくても古くても)再インデックスされるため、ブランチ切り替え後も正確にインデックスが更新されます。`--files` / `--commits` の部分更新も同じパスフィルタを再利用し、commit 側で ignore ファイルが変わったときは自動でフルスキャンへ切り替わります。Git 管理下の ignore 判定は OS 固定ではなく `core.ignorecase` を参照し、`**` も Git の path-form globstar だけを特別扱いするため、差分更新でも Git と同じ範囲で ignore されます。つまり ignore ルール自体が変わらない限り、差分再インデックスは実際に変わったファイルだけに比例します。 -index 時には `--include-symbol-kind` で一致する kind だけを保持し、`--exclude-symbol-kind` で一致する kind を `symbols` に書き込む前に除外できます。値はカンマ区切りで、大文字小文字は区別しません。両方を指定した場合は include を先に適用し、重複した kind では exclude が優先されます。正規化済み policy は current generation 用に永続化され、各 file に除外 symbol 数が記録されます。index、`status --json`、workspace status、MCP response は `symbol_kind_filter_provenance_available`、`symbol_kind_filter`、generation 全体の `symbols_dropped_by_kind_filter` を返します。active な policy があれば、実際の除外数が0でも repository 全体の否定結果を表さないため、`symbol_kind_filter_coverage_limited` とともに `index_complete=false`、`graph_data_current=false`、`reference_graph_complete=false` になります。policy provenance を持たない legacy DB は読み取り可能なまま保守的な `symbol_kind_filter_provenance_unavailable` fallback を使い、workspace 全体の refresh で現行 provenance を stamp するまでは scoped update を拒否します。row-only JSON array / NDJSON の symbol query も0件時の authority warning を stderr に出力しますが、symbol-kind filter の影響を受けない file・text-search・validation count は authoritative のままです。dry-run JSON も同じ policy を DB ベースの `estimated_table_mutations.symbols` 推計へ適用します。symbol-kind filter は reference 行を絞り込まないため、reference の推計値は変わりません。 +index 時には `--include-symbol-kind` で一致する kind だけを保持し、`--exclude-symbol-kind` で一致する kind を `symbols` に書き込む前に除外できます。値はカンマ区切りで、大文字小文字は区別しません。両方を指定した場合は include を先に適用し、重複した kind では exclude が優先されます。正規化済み policy は current generation 用に永続化され、各 file に除外 symbol 数が記録されます。index、`status --json`、workspace status、MCP response は `symbol_kind_filter_provenance_available`、`symbol_kind_filter`、generation 全体の `symbols_dropped_by_kind_filter` を返します。active な policy があれば、実際の除外数が0でも repository 全体の否定結果を表さないため、`symbol_kind_filter_coverage_limited` とともに `index_complete=false`、`graph_data_current=false`、`reference_graph_complete=false` になります。policy provenance を持たない legacy DB は読み取り可能なまま保守的な `symbol_kind_filter_provenance_unavailable` fallback を使い、workspace 全体の refresh で現行 provenance を stamp するまでは scoped update を拒否します。row-only JSON array / NDJSON の symbol query も0件時の authority warning を stderr に出力しますが、symbol-kind filter の影響を受けない file・text-search・validation count は authoritative のままです。dry-run JSON も同じ policy を `table_row_estimates.symbols.rows_inserted_or_upserted` と、それに依存する operation / final-state 推定へ適用します。非推奨の `estimated_table_mutations.symbols` alias は同じ総 row-operation 値を返します。symbol-kind filter は reference 行を絞り込まないため、reference の推計値は変わりません。 ### インクリメンタル更新の信頼性 diff --git a/changelog.d/unreleased/5236.fixed.md b/changelog.d/unreleased/5236.fixed.md new file mode 100644 index 000000000..0357e97bc --- /dev/null +++ b/changelog.d/unreleased/5236.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 5236 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs + - README.md + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Dry-run row operations are now distinct from projected table state (#5236)** — Building on the earlier dry-run mutation estimates from #3569 and #4893, `index --dry-run` reports per-table deletes, inserts/upserts, total row operations, projected final rows, and projected deltas with independent confidence and unknown reasons. Cross-file TypeScript augmentation rebuilds conservatively report affected symbol-reference dimensions as unknown. Deprecated legacy mutation fields remain explicit total-operation aliases during the compatibility window. + +## 日本語 + +- **dry-run の row operation と予測 table state を分離しました (#5236)** — #3569 と #4893 の従来の dry-run mutation 推定を基礎として、`index --dry-run` は table ごとの delete、insert/upsert、総 row operation、予測最終 row 数、予測 delta を独立した confidence / unknown reason 付きで返します。cross-file TypeScript augmentation rebuild では、影響する symbol-reference dimension を保守的に unknown として返します。非推奨の従来 mutation field は互換期間中、明示的な総 operation alias として維持されます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 524a5bc52..0b465b522 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -771,7 +771,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--dry-run", - Description = "Preview without writing; hooks supports install and uninstall", + Description = "Preview without writing; index reports per-table row operations and projected state; hooks supports install and uninstall", PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum", "import"), CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) { diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 880e54c0b..17c2debe6 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -108,9 +108,9 @@ private static int RunDryRun( var retainedRelativePaths = new HashSet<string>(StringComparer.Ordinal); var projectedDeletePaths = new HashSet<string>(StringComparer.Ordinal); var projectedPurgePaths = new HashSet<string>(StringComparer.Ordinal); - var mutationEstimates = new DryRunMutationEstimateAccumulator(); + var mutationEstimates = new DryRunMutationEstimateAccumulator(dbSnapshot); if (dbSnapshot.ReadFailed) - mutationEstimates.MarkAllUnknown("index_snapshot_unavailable"); + mutationEstimates.MarkSnapshotUnknown("index_snapshot_unavailable"); var estimatedSymbolsDroppedByKindFilter = 0L; var projectedFileUpdates = 0; var projectedFileSkips = 0; @@ -510,7 +510,8 @@ bool TryApplyProjectedCSharpPurgeEvidence() AddEstimatedExistingUpdateMutations( mutationEstimates, dbSnapshot, - dbRelativePath); + dbRelativePath, + probe.Language); mutationEstimates.AddParsedEstimate(new DryRunParsedMutationEstimate( 0, 0, @@ -626,7 +627,8 @@ bool TryApplyProjectedCSharpPurgeEvidence() AddEstimatedExistingUpdateMutations( mutationEstimates, dbSnapshot, - dbRelativePath); + dbRelativePath, + probe.Language); if (parseEstimateFilesProcessed >= DryRunParseEstimateFileLimit) { parseEstimateFilesTruncated = true; @@ -784,8 +786,17 @@ bool TryApplyProjectedCSharpPurgeEvidence() var projectionAuthoritative = projectionUnavailableReasons.Count == 0; - var estimatedTableMutations = mutationEstimates.BuildValues(); - var estimatedTableMutationDetails = mutationEstimates.BuildDetails(); + var tableRowEstimates = mutationEstimates.BuildTableRowEstimates(); + var estimatedTableMutations = tableRowEstimates.ToDictionary( + static pair => pair.Key, + static pair => pair.Value.RowOperations.Value, + StringComparer.Ordinal); + var estimatedTableMutationDetails = tableRowEstimates.ToDictionary( + static pair => pair.Key, + static pair => string.Equals(pair.Key, "files", StringComparison.Ordinal) + ? pair.Value.RowOperations with { Source = "filesystem_plan" } + : pair.Value.RowOperations, + StringComparer.Ordinal); var unknownExtensionClassification = UnknownExtensionClassifier.Classify(unknownExtensionPaths); var unknownExtensionGroups = unknownExtensionClassification.Groups .Take(UnknownExtensionClassifier.MaxCompletionGroups) @@ -853,6 +864,7 @@ bool TryApplyProjectedCSharpPurgeEvidence() ParseEstimateFileLimit = DryRunParseEstimateFileLimit, ParseEstimateFilesProcessed = parseEstimateFilesProcessed, ParseEstimateFilesTruncated = parseEstimateFilesTruncated, + TableRowEstimates = tableRowEstimates, EstimatedTableMutations = estimatedTableMutations, EstimatedTableMutationDetails = estimatedTableMutationDetails, SymbolsDroppedByKindFilter = estimatedSymbolsDroppedByKindFilter, @@ -908,13 +920,12 @@ bool TryApplyProjectedCSharpPurgeEvidence() CommandOutputWriter.WriteLine($" projected reference cap hits {projectedReferenceCapHits,6}"); foreach (var metric in DryRunMutationEstimateAccumulator.MetricNames) { - var estimate = estimatedTableMutationDetails[metric]; - var value = estimate.Value?.ToString("N0", System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"; - var reasons = estimate.UnknownReasons.Count == 0 - ? string.Empty - : $"; reason {string.Join(",", estimate.UnknownReasons)}"; - CommandOutputWriter.WriteLine( - $" estimated {metric,-17} {value,10} ({estimate.Source}, {estimate.Confidence}{reasons})"); + var estimate = tableRowEstimates[metric]; + WriteDryRunTableRowEstimate(metric, "rows deleted", estimate.RowsDeleted); + WriteDryRunTableRowEstimate(metric, "rows inserted/upserted", estimate.RowsInsertedOrUpserted); + WriteDryRunTableRowEstimate(metric, "row operations", estimate.RowOperations); + WriteDryRunTableRowEstimate(metric, "projected final rows", estimate.ProjectedFinalRows); + WriteDryRunTableRowEstimate(metric, "projected row delta", estimate.ProjectedRowDelta); } if (parseEstimateFilesTruncated) CommandOutputWriter.WriteLine($" parse estimates capped at {DryRunParseEstimateFileLimit.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} update files"); @@ -931,6 +942,21 @@ bool TryApplyProjectedCSharpPurgeEvidence() return CommandExitCodes.Success; } + private static void WriteDryRunTableRowEstimate( + string metric, + string dimension, + IndexDryRunEstimateJsonResult estimate) + { + var value = estimate.Value?.ToString( + "N0", + System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"; + var reasons = estimate.UnknownReasons.Count == 0 + ? string.Empty + : $"; reason {string.Join(",", estimate.UnknownReasons)}"; + CommandOutputWriter.WriteLine( + $" {metric,-17} {dimension,-24} {value,10} ({estimate.Source}, {estimate.Confidence}{reasons})"); + } + private static bool TryResolveDryRunCandidates( IndexCommandOptions options, FileIndexer dryIndexer, @@ -1821,10 +1847,21 @@ private static bool DryRunExtractorContractsMatchCurrent( private static void AddEstimatedExistingUpdateMutations( DryRunMutationEstimateAccumulator mutations, DryRunDbSnapshot snapshot, - string relativePath) + string relativePath, + string? projectedLanguage = null) { - mutations.Add("files", 1); - if (!snapshot.Files.TryGetValue(relativePath, out var rows)) + var replacesExisting = snapshot.Files.TryGetValue(relativePath, out var rows); + mutations.AddInsertOrUpsert( + "files", + 1, + projectedRowAddition: replacesExisting ? 0 : 1); + if (string.Equals(projectedLanguage, "typescript", StringComparison.Ordinal) + || (replacesExisting + && string.Equals(rows.Language, "typescript", StringComparison.Ordinal))) + { + mutations.MarkTypeScriptAugmentationUnknown(); + } + if (!replacesExisting) return; AddExistingChildRows(mutations, snapshot, rows, rows.Symbols); @@ -1838,7 +1875,9 @@ private static void AddEstimatedDeleteMutation( if (!snapshot.Files.TryGetValue(relativePath, out var rows)) return; - mutations.Add("files", 1); + mutations.AddDelete("files", 1); + if (string.Equals(rows.Language, "typescript", StringComparison.Ordinal)) + mutations.MarkTypeScriptAugmentationUnknown(); AddExistingChildRows(mutations, snapshot, rows, rows.Symbols); } @@ -1848,17 +1887,17 @@ private static void AddExistingChildRows( DryRunExistingFileRows rows, long symbols) { - mutations.AddExisting("chunks", rows.Chunks, snapshot.ChunksAvailable); - mutations.AddExisting("symbols", symbols, snapshot.SymbolsAvailable); - mutations.AddExisting( + mutations.AddExistingDelete("chunks", rows.Chunks, snapshot.ChunksAvailable); + mutations.AddExistingDelete("symbols", symbols, snapshot.SymbolsAvailable); + mutations.AddExistingDelete( "symbol_references", rows.SymbolReferences, snapshot.SymbolReferencesAvailable); - mutations.AddExisting( + mutations.AddExistingDelete( "reference_lines", rows.ReferenceLines, snapshot.ReferenceLinesAvailable); - mutations.AddExisting("file_issues", rows.FileIssues, snapshot.FileIssuesAvailable); + mutations.AddExistingDelete("file_issues", rows.FileIssues, snapshot.FileIssuesAvailable); } private static DryRunDbSnapshot ReadDryRunDbSnapshot( @@ -2257,84 +2296,340 @@ private sealed class DryRunMutationEstimateAccumulator "file_issues", ]; - private readonly Dictionary<string, long> values = MetricNames.ToDictionary( - static metric => metric, - static _ => 0L, - StringComparer.Ordinal); - private readonly Dictionary<string, SortedSet<string>> unknownReasons = MetricNames.ToDictionary( - static metric => metric, - static _ => new SortedSet<string>(StringComparer.Ordinal), - StringComparer.Ordinal); + private readonly DryRunEstimateDimensionState initialRows = new(); + private readonly DryRunEstimateDimensionState rowsDeleted = new(); + private readonly DryRunEstimateDimensionState rowsInsertedOrUpserted = new(); + private readonly DryRunEstimateDimensionState projectedRowAdditions = new(); + private bool childInsertionRequiresParseEstimate; + + internal DryRunMutationEstimateAccumulator(DryRunDbSnapshot snapshot) + { + InitializeInitialRows(snapshot); + } + + internal void AddInsertOrUpsert( + string metric, + long value, + long? projectedRowAddition = null) + { + rowsInsertedOrUpserted.Add(metric, value); + projectedRowAdditions.Add( + metric, + projectedRowAddition ?? value); + } - internal void Add(string metric, long value) - => values[metric] += value; + internal void AddDelete(string metric, long value) + => rowsDeleted.Add(metric, value); - internal void AddExisting(string metric, long value, bool available) + internal void AddExistingDelete(string metric, long value, bool available) { if (!available) { - MarkUnknown(metric, "existing_table_unavailable"); + rowsDeleted.MarkUnknown(metric, "existing_table_unavailable"); return; } - Add(metric, value); + AddDelete(metric, value); } internal void AddParsedEstimate(DryRunParsedMutationEstimate estimate) { - Add("chunks", estimate.Chunks); - Add("symbols", estimate.Symbols); - Add("symbol_references", estimate.SymbolReferences); - Add("reference_lines", estimate.ReferenceLines); - Add("file_issues", estimate.FileIssues); + childInsertionRequiresParseEstimate = true; + AddInsertOrUpsert("chunks", estimate.Chunks); + AddInsertOrUpsert("symbols", estimate.Symbols); + AddInsertOrUpsert("symbol_references", estimate.SymbolReferences); + AddInsertOrUpsert("reference_lines", estimate.ReferenceLines); + AddInsertOrUpsert("file_issues", estimate.FileIssues); } internal void MarkParseUnknown(string reason) { + childInsertionRequiresParseEstimate = true; foreach (var metric in MetricNames) { if (metric != "files") - MarkUnknown(metric, reason); + { + rowsInsertedOrUpserted.MarkUnknown(metric, reason); + projectedRowAdditions.MarkUnknown(metric, reason); + } } } + internal void MarkTypeScriptAugmentationUnknown() + { + const string reason = "typescript_augmentation_rebuild_required"; + rowsDeleted.MarkUnknown("symbol_references", reason); + rowsInsertedOrUpserted.MarkUnknown("symbol_references", reason); + projectedRowAdditions.MarkUnknown("symbol_references", reason); + } + internal void MarkAllUnknown(string reason) + { + childInsertionRequiresParseEstimate = true; + foreach (var metric in MetricNames) + { + initialRows.MarkUnknown(metric, reason); + rowsDeleted.MarkUnknown(metric, reason); + rowsInsertedOrUpserted.MarkUnknown(metric, reason); + projectedRowAdditions.MarkUnknown(metric, reason); + } + } + + internal void MarkSnapshotUnknown(string reason) { foreach (var metric in MetricNames) - MarkUnknown(metric, reason); + { + initialRows.MarkUnknown(metric, reason); + rowsDeleted.MarkUnknown(metric, reason); + } + projectedRowAdditions.MarkUnknown("files", reason); } - internal Dictionary<string, long?> BuildValues() + internal Dictionary<string, IndexDryRunTableRowEstimateJsonResult> + BuildTableRowEstimates() => MetricNames.ToDictionary( static metric => metric, - metric => unknownReasons[metric].Count == 0 ? (long?)values[metric] : null, + BuildTableRowEstimate, StringComparer.Ordinal); - internal Dictionary<string, IndexDryRunEstimateJsonResult> BuildDetails() - => MetricNames.ToDictionary( - static metric => metric, - metric => + private void InitializeInitialRows(DryRunDbSnapshot snapshot) + { + if (snapshot.ReadFailed) + return; + + initialRows.Add("files", snapshot.Files.Count); + InitializeExistingChildRows( + "chunks", + snapshot, + snapshot.ChunksAvailable, + static rows => rows.Chunks); + InitializeExistingChildRows( + "symbols", + snapshot, + snapshot.SymbolsAvailable, + static rows => rows.Symbols); + InitializeExistingChildRows( + "symbol_references", + snapshot, + snapshot.SymbolReferencesAvailable, + static rows => rows.SymbolReferences); + InitializeExistingChildRows( + "reference_lines", + snapshot, + snapshot.ReferenceLinesAvailable, + static rows => rows.ReferenceLines); + InitializeExistingChildRows( + "file_issues", + snapshot, + snapshot.FileIssuesAvailable, + static rows => rows.FileIssues); + } + + private void InitializeExistingChildRows( + string metric, + DryRunDbSnapshot snapshot, + bool tableAvailable, + Func<DryRunExistingFileRows, long> selectValue) + { + if (!tableAvailable && snapshot.Files.Count > 0) + { + initialRows.MarkUnknown(metric, "existing_table_unavailable"); + return; + } + + foreach (var rows in snapshot.Files.Values) + initialRows.Add(metric, selectValue(rows)); + } + + private IndexDryRunTableRowEstimateJsonResult BuildTableRowEstimate( + string metric) + { + var fileMetric = string.Equals(metric, "files", StringComparison.Ordinal); + var parseDerived = !fileMetric && childInsertionRequiresParseEstimate; + var deleted = BuildDirectEstimate( + rowsDeleted, + metric, + "index_snapshot", + "exact"); + var insertedOrUpserted = BuildDirectEstimate( + rowsInsertedOrUpserted, + metric, + parseDerived ? "parse_only" : "filesystem_plan", + parseDerived ? "estimate" : "exact"); + var operations = BuildBinaryEstimate( + rowsDeleted, + rowsInsertedOrUpserted, + metric, + parseDerived + ? "parse_only_and_index_snapshot" + : "filesystem_plan_and_index_snapshot", + parseDerived ? "estimate" : "exact", + static (left, right) => checked(left + right)); + var projectedFinalRows = BuildTernaryEstimate( + initialRows, + rowsDeleted, + projectedRowAdditions, + metric, + parseDerived + ? "parse_only_and_index_snapshot" + : "filesystem_plan_and_index_snapshot", + parseDerived ? "estimate" : "exact", + static (initial, deletes, additions) => checked(initial - deletes + additions), + rejectNegative: true); + var projectedRowDelta = BuildBinaryEstimate( + projectedRowAdditions, + rowsDeleted, + metric, + parseDerived + ? "parse_only_and_index_snapshot" + : "filesystem_plan_and_index_snapshot", + parseDerived ? "estimate" : "exact", + static (additions, deletes) => checked(additions - deletes)); + + return new IndexDryRunTableRowEstimateJsonResult( + deleted, + insertedOrUpserted, + operations, + projectedFinalRows, + projectedRowDelta); + } + + private static IndexDryRunEstimateJsonResult BuildDirectEstimate( + DryRunEstimateDimensionState state, + string metric, + string source, + string knownConfidence) + { + var (value, reasons) = state.Get(metric); + return BuildEstimate(value, source, knownConfidence, reasons); + } + + private static IndexDryRunEstimateJsonResult BuildBinaryEstimate( + DryRunEstimateDimensionState left, + DryRunEstimateDimensionState right, + string metric, + string source, + string knownConfidence, + Func<long, long, long> calculate) + { + var (leftValue, leftReasons) = left.Get(metric); + var (rightValue, rightReasons) = right.Get(metric); + var reasons = MergeReasons(leftReasons, rightReasons); + long? value = null; + if (reasons.Count == 0 && leftValue.HasValue && rightValue.HasValue) + { + try + { + value = calculate(leftValue.Value, rightValue.Value); + } + catch (OverflowException) { - var reasons = unknownReasons[metric].ToList(); - var value = reasons.Count == 0 ? (long?)values[metric] : null; - var source = metric == "files" - ? "filesystem_plan" - : "parse_only_and_index_snapshot"; - var confidence = reasons.Count > 0 - ? "unknown" - : metric == "files" - ? "exact" - : "estimate"; - return new IndexDryRunEstimateJsonResult( - value, - source, - confidence, - reasons); - }, + reasons.Add("arithmetic_overflow"); + } + } + + return BuildEstimate(value, source, knownConfidence, reasons); + } + + private static IndexDryRunEstimateJsonResult BuildTernaryEstimate( + DryRunEstimateDimensionState first, + DryRunEstimateDimensionState second, + DryRunEstimateDimensionState third, + string metric, + string source, + string knownConfidence, + Func<long, long, long, long> calculate, + bool rejectNegative) + { + var (firstValue, firstReasons) = first.Get(metric); + var (secondValue, secondReasons) = second.Get(metric); + var (thirdValue, thirdReasons) = third.Get(metric); + var reasons = MergeReasons( + firstReasons, + secondReasons, + thirdReasons); + long? value = null; + if (reasons.Count == 0 + && firstValue.HasValue + && secondValue.HasValue + && thirdValue.HasValue) + { + try + { + value = calculate( + firstValue.Value, + secondValue.Value, + thirdValue.Value); + if (rejectNegative && value.Value < 0) + { + value = null; + reasons.Add("projected_row_count_negative"); + } + } + catch (OverflowException) + { + reasons.Add("arithmetic_overflow"); + } + } + + return BuildEstimate(value, source, knownConfidence, reasons); + } + + private static IndexDryRunEstimateJsonResult BuildEstimate( + long? value, + string source, + string knownConfidence, + List<string> reasons) + => new( + reasons.Count == 0 ? value : null, + source, + reasons.Count == 0 ? knownConfidence : "unknown", + reasons); + + private static List<string> MergeReasons( + params IReadOnlyList<string>[] reasonSets) + { + var merged = new SortedSet<string>(StringComparer.Ordinal); + foreach (var reasonSet in reasonSets) + merged.UnionWith(reasonSet); + return merged.ToList(); + } + + private sealed class DryRunEstimateDimensionState + { + private readonly Dictionary<string, long> values = MetricNames.ToDictionary( + static metric => metric, + static _ => 0L, + StringComparer.Ordinal); + private readonly Dictionary<string, SortedSet<string>> unknownReasons = MetricNames.ToDictionary( + static metric => metric, + static _ => new SortedSet<string>(StringComparer.Ordinal), StringComparer.Ordinal); - private void MarkUnknown(string metric, string reason) - => unknownReasons[metric].Add(reason); + internal void Add(string metric, long value) + { + try + { + values[metric] = checked(values[metric] + value); + } + catch (OverflowException) + { + MarkUnknown(metric, "arithmetic_overflow"); + } + } + + internal void MarkUnknown(string metric, string reason) + => unknownReasons[metric].Add(reason); + + internal (long? Value, List<string> UnknownReasons) Get( + string metric) + { + var reasons = unknownReasons[metric].ToList(); + return ( + reasons.Count == 0 ? values[metric] : null, + reasons); + } + } } private sealed record DryRunDbSnapshot( diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 541337200..185585091 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -952,6 +952,13 @@ internal sealed record IndexDryRunEstimateJsonResult( [property: JsonPropertyName("confidence")] string Confidence, [property: JsonPropertyName("unknown_reasons")] List<string> UnknownReasons); +internal sealed record IndexDryRunTableRowEstimateJsonResult( + [property: JsonPropertyName("rows_deleted")] IndexDryRunEstimateJsonResult RowsDeleted, + [property: JsonPropertyName("rows_inserted_or_upserted")] IndexDryRunEstimateJsonResult RowsInsertedOrUpserted, + [property: JsonPropertyName("row_operations")] IndexDryRunEstimateJsonResult RowOperations, + [property: JsonPropertyName("projected_final_rows")] IndexDryRunEstimateJsonResult ProjectedFinalRows, + [property: JsonPropertyName("projected_row_delta")] IndexDryRunEstimateJsonResult ProjectedRowDelta); + internal sealed class IndexDryRunJsonResult : IVersionedJsonResult { public string ApiVersion { get; init; } = JsonOutputContract.ApiVersion; @@ -989,8 +996,12 @@ internal sealed class IndexDryRunJsonResult : IVersionedJsonResult public int ParseEstimateFileLimit { get; init; } public int ParseEstimateFilesProcessed { get; init; } public bool ParseEstimateFilesTruncated { get; init; } + public Dictionary<string, IndexDryRunTableRowEstimateJsonResult> TableRowEstimates { get; init; } = new(); public Dictionary<string, long?> EstimatedTableMutations { get; init; } = new(); public Dictionary<string, IndexDryRunEstimateJsonResult> EstimatedTableMutationDetails { get; init; } = new(); + public string EstimatedTableMutationsSemantics { get; init; } = "row_operations"; + public bool EstimatedTableMutationsDeprecated { get; init; } = true; + public string EstimatedTableMutationsReplacement { get; init; } = "table_row_estimates.<table>.row_operations"; public long SymbolsDroppedByKindFilter { get; init; } public IndexSymbolKindFilterJsonResult SymbolKindFilter { get; init; } = new(); public List<string>? FileSamples { get; init; } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs index 27413155e..e5a32d49a 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs @@ -166,6 +166,325 @@ public void Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWor } } + [Fact] + public void Run_DryRun_TableRowEstimatesSeparateOperationsFromProjectedState_Issue5236() + { + var projectRoot = CreateTempProject(); + var tableNames = new[] + { + "files", + "chunks", + "symbols", + "symbol_references", + "reference_lines", + "file_issues", + }; + try + { + var sourcePath = Path.Combine(projectRoot, "app.cs"); + File.WriteAllText( + sourcePath, + """ + public class App + { + public void First() => Second(); + public void Second() { } + } + """); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + + var (newDatabaseExitCode, newDatabase) = RunAndCaptureJson([ + projectRoot, + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, newDatabaseExitCode); + Assert.Equal("row_operations", newDatabase.GetProperty("estimated_table_mutations_semantics").GetString()); + Assert.True(newDatabase.GetProperty("estimated_table_mutations_deprecated").GetBoolean()); + Assert.Equal( + "table_row_estimates.<table>.row_operations", + newDatabase.GetProperty("estimated_table_mutations_replacement").GetString()); + Assert.Equal( + "filesystem_plan", + newDatabase + .GetProperty("estimated_table_mutation_details") + .GetProperty("files") + .GetProperty("source") + .GetString()); + foreach (var tableName in tableNames) + { + var estimate = GetDryRunTableRowEstimate(newDatabase, tableName); + var inserted = GetDryRunEstimateValue(estimate, "rows_inserted_or_upserted"); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "rows_deleted")); + Assert.Equal(inserted, GetDryRunEstimateValue(estimate, "row_operations")); + Assert.Equal(inserted, GetDryRunEstimateValue(estimate, "projected_final_rows")); + Assert.Equal(inserted, GetDryRunEstimateValue(estimate, "projected_row_delta")); + } + var newSymbolEstimate = GetDryRunTableRowEstimate(newDatabase, "symbols"); + AssertDryRunEstimateMetadata( + newSymbolEstimate.GetProperty("rows_deleted"), + "index_snapshot", + "exact"); + AssertDryRunEstimateMetadata( + newSymbolEstimate.GetProperty("rows_inserted_or_upserted"), + "parse_only", + "estimate"); + AssertDryRunEstimateMetadata( + newSymbolEstimate.GetProperty("row_operations"), + "parse_only_and_index_snapshot", + "estimate"); + AssertDryRunEstimateMetadata( + newSymbolEstimate.GetProperty("projected_final_rows"), + "parse_only_and_index_snapshot", + "estimate"); + AssertDryRunEstimateMetadata( + GetDryRunTableRowEstimate(newDatabase, "files").GetProperty("row_operations"), + "filesystem_plan_and_index_snapshot", + "exact"); + Assert.False(Directory.Exists(Path.Combine(projectRoot, ".cdidx"))); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + var initialCounts = tableNames.ToDictionary( + static tableName => tableName, + tableName => (long)CountRows(dbPath, tableName), + StringComparer.Ordinal); + var databaseBeforeRebuildPreview = ReadDatabaseFileSetFingerprint(dbPath); + + var (humanExitCode, humanOutput, _) = RunAndCaptureStreams([ + projectRoot, + "--rebuild", + "--dry-run", + ]); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Contains("rows deleted", humanOutput); + Assert.Contains("rows inserted/upserted", humanOutput); + Assert.Contains("row operations", humanOutput); + Assert.Contains("projected final rows", humanOutput); + Assert.Contains("projected row delta", humanOutput); + + var (rebuildExitCode, rebuild) = RunAndCaptureJson([ + projectRoot, + "--rebuild", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, rebuildExitCode); + foreach (var tableName in tableNames) + { + var estimate = GetDryRunTableRowEstimate(rebuild, tableName); + var expectedDeletes = tableName == "files" ? 0 : initialCounts[tableName]; + var expectedInserts = initialCounts[tableName]; + Assert.Equal(expectedDeletes, GetDryRunEstimateValue(estimate, "rows_deleted")); + Assert.Equal(expectedInserts, GetDryRunEstimateValue(estimate, "rows_inserted_or_upserted")); + Assert.Equal(expectedDeletes + expectedInserts, GetDryRunEstimateValue(estimate, "row_operations")); + Assert.Equal(initialCounts[tableName], GetDryRunEstimateValue(estimate, "projected_final_rows")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "projected_row_delta")); + Assert.Equal( + GetDryRunEstimateValue(estimate, "row_operations"), + rebuild.GetProperty("estimated_table_mutations").GetProperty(tableName).GetInt64()); + } + Assert.Equal(databaseBeforeRebuildPreview, ReadDatabaseFileSetFingerprint(dbPath)); + + File.AppendAllText( + sourcePath, + "\npublic class Added { public void Third() => new App().First(); }\n"); + var databaseBeforeChangedPreview = ReadDatabaseFileSetFingerprint(dbPath); + var (changedExitCode, changed) = RunAndCaptureJson([ + projectRoot, + "--files", + "app.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, changedExitCode); + foreach (var tableName in tableNames) + { + var estimate = GetDryRunTableRowEstimate(changed, tableName); + var expectedDeletes = tableName == "files" ? 0 : initialCounts[tableName]; + var inserted = GetDryRunEstimateValue(estimate, "rows_inserted_or_upserted"); + var expectedDelta = tableName == "files" + ? 0 + : inserted - initialCounts[tableName]; + Assert.Equal(expectedDeletes, GetDryRunEstimateValue(estimate, "rows_deleted")); + Assert.Equal(expectedDeletes + inserted, GetDryRunEstimateValue(estimate, "row_operations")); + Assert.Equal(inserted, GetDryRunEstimateValue(estimate, "projected_final_rows")); + Assert.Equal(expectedDelta, GetDryRunEstimateValue(estimate, "projected_row_delta")); + } + Assert.Equal(databaseBeforeChangedPreview, ReadDatabaseFileSetFingerprint(dbPath)); + + var (changedApplyExitCode, _) = RunAndCaptureJson([ + projectRoot, + "--files", + "app.cs", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, changedApplyExitCode); + foreach (var tableName in tableNames) + { + Assert.Equal( + GetDryRunEstimateValue( + GetDryRunTableRowEstimate(changed, tableName), + "projected_final_rows"), + CountRows(dbPath, tableName)); + } + + var currentCounts = tableNames.ToDictionary( + static tableName => tableName, + tableName => (long)CountRows(dbPath, tableName), + StringComparer.Ordinal); + var (skipExitCode, skip) = RunAndCaptureJson([ + projectRoot, + "--files", + "app.cs", + "--dry-run", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, skipExitCode); + Assert.Equal(1, skip.GetProperty("projected_file_skips").GetInt32()); + foreach (var tableName in tableNames) + { + var estimate = GetDryRunTableRowEstimate(skip, tableName); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "rows_deleted")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "rows_inserted_or_upserted")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "row_operations")); + Assert.Equal(currentCounts[tableName], GetDryRunEstimateValue(estimate, "projected_final_rows")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "projected_row_delta")); + if (tableName != "files") + { + AssertDryRunEstimateMetadata( + estimate.GetProperty("rows_inserted_or_upserted"), + "filesystem_plan", + "exact"); + foreach (var dimension in new[] + { + "row_operations", + "projected_final_rows", + "projected_row_delta", + }) + { + AssertDryRunEstimateMetadata( + estimate.GetProperty(dimension), + "filesystem_plan_and_index_snapshot", + "exact"); + } + } + } + + File.Delete(sourcePath); + var databaseBeforeDeletePreview = ReadDatabaseFileSetFingerprint(dbPath); + var (deleteExitCode, delete) = RunAndCaptureJson([ + projectRoot, + "--files", + "app.cs", + "--dry-run", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, deleteExitCode); + Assert.Equal(1, delete.GetProperty("projected_file_deletes").GetInt32()); + foreach (var tableName in tableNames) + { + var estimate = GetDryRunTableRowEstimate(delete, tableName); + Assert.Equal(currentCounts[tableName], GetDryRunEstimateValue(estimate, "rows_deleted")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "rows_inserted_or_upserted")); + Assert.Equal(currentCounts[tableName], GetDryRunEstimateValue(estimate, "row_operations")); + Assert.Equal(0, GetDryRunEstimateValue(estimate, "projected_final_rows")); + Assert.Equal(-currentCounts[tableName], GetDryRunEstimateValue(estimate, "projected_row_delta")); + if (tableName != "files") + { + AssertDryRunEstimateMetadata( + estimate.GetProperty("rows_inserted_or_upserted"), + "filesystem_plan", + "exact"); + foreach (var dimension in new[] + { + "row_operations", + "projected_final_rows", + "projected_row_delta", + }) + { + AssertDryRunEstimateMetadata( + estimate.GetProperty(dimension), + "filesystem_plan_and_index_snapshot", + "exact"); + } + } + } + Assert.Equal(databaseBeforeDeletePreview, ReadDatabaseFileSetFingerprint(dbPath)); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_TypeScriptAugmentationRebuildMarksSymbolReferenceDimensionsUnknown_Issue5236() + { + var projectRoot = CreateTempProject(); + try + { + var deletedPath = Path.Combine(projectRoot, "deleted.ts"); + File.WriteAllText(deletedPath, "interface Shared { deleted: string }\n"); + File.WriteAllText( + Path.Combine(projectRoot, "retained.ts"), + "interface Shared { retained: number }\n"); + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(2, CountRows(dbPath, "symbol_references")); + File.Delete(deletedPath); + var databaseBeforePreview = ReadDatabaseFileSetFingerprint(dbPath); + + var (dryRunExitCode, dryRun) = RunAndCaptureJson([ + projectRoot, + "--files", + "deleted.ts", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal(1, dryRun.GetProperty("projected_file_deletes").GetInt32()); + var referenceEstimate = GetDryRunTableRowEstimate(dryRun, "symbol_references"); + foreach (var dimension in new[] + { + "rows_deleted", + "rows_inserted_or_upserted", + "row_operations", + "projected_final_rows", + "projected_row_delta", + }) + { + var estimate = referenceEstimate.GetProperty(dimension); + Assert.Equal("unknown", estimate.GetProperty("confidence").GetString()); + Assert.Contains( + "typescript_augmentation_rebuild_required", + estimate.GetProperty("unknown_reasons") + .EnumerateArray() + .Select(static value => value.GetString())); + } + Assert.Equal(databaseBeforePreview, ReadDatabaseFileSetFingerprint(dbPath)); + + var (applyExitCode, _) = RunAndCaptureJson([ + projectRoot, + "--files", + "deleted.ts", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, applyExitCode); + Assert.Equal(0, CountRows(dbPath, "symbol_references")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_WithChangedBetweenMissingRef_ReturnsUsageError() { @@ -412,8 +731,8 @@ public void Third() { } var (humanExitCode, humanOutput, _) = RunAndCaptureStreams([projectRoot, "--dry-run"]); Assert.Equal(CommandExitCodes.Success, humanExitCode); Assert.Contains("projected updates", humanOutput); - Assert.Contains("estimated chunks", humanOutput); - Assert.Contains("estimated symbols", humanOutput); + Assert.Matches("chunks\\s+row operations", humanOutput); + Assert.Matches("symbols\\s+row operations", humanOutput); Assert.Contains("parse_only_and_index_snapshot", humanOutput); Assert.Contains("estimate", humanOutput); @@ -1794,6 +2113,26 @@ public void Run_DryRun_ParseEstimateFailureReturnsExplicitUnknown_Issue4893() Assert.Contains( "parse_estimation_failed", detail.GetProperty("unknown_reasons").EnumerateArray().Select(value => value.GetString())); + var rowEstimate = GetDryRunTableRowEstimate(json, "symbols"); + var deleted = rowEstimate.GetProperty("rows_deleted"); + Assert.Equal(0, deleted.GetProperty("value").GetInt64()); + Assert.Equal("exact", deleted.GetProperty("confidence").GetString()); + Assert.Empty(deleted.GetProperty("unknown_reasons").EnumerateArray()); + foreach (var dimension in new[] + { + "rows_inserted_or_upserted", + "row_operations", + "projected_final_rows", + "projected_row_delta", + }) + { + var unknown = rowEstimate.GetProperty(dimension); + Assert.Equal(JsonValueKind.Null, unknown.GetProperty("value").ValueKind); + Assert.Equal("unknown", unknown.GetProperty("confidence").GetString()); + Assert.Contains( + "parse_estimation_failed", + unknown.GetProperty("unknown_reasons").EnumerateArray().Select(value => value.GetString())); + } Assert.Equal(1, json.GetProperty("errors_total").GetInt32()); Assert.Contains( "Parse-only mutation estimate unavailable", @@ -2328,6 +2667,12 @@ public void Run_DryRun_WithFiles_ReportsChecksumRenamePurgeWithoutWriting() Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 2); + var fileEstimate = GetDryRunTableRowEstimate(json, "files"); + Assert.Equal(1, GetDryRunEstimateValue(fileEstimate, "rows_deleted")); + Assert.Equal(1, GetDryRunEstimateValue(fileEstimate, "rows_inserted_or_upserted")); + Assert.Equal(2, GetDryRunEstimateValue(fileEstimate, "row_operations")); + Assert.Equal(1, GetDryRunEstimateValue(fileEstimate, "projected_final_rows")); + Assert.Equal(0, GetDryRunEstimateValue(fileEstimate, "projected_row_delta")); Assert.Equal(1, CountRows(dbPath, "files")); } finally @@ -2336,6 +2681,26 @@ public void Run_DryRun_WithFiles_ReportsChecksumRenamePurgeWithoutWriting() } } + private static JsonElement GetDryRunTableRowEstimate( + JsonElement result, + string tableName) + => result.GetProperty("table_row_estimates").GetProperty(tableName); + + private static long GetDryRunEstimateValue( + JsonElement tableEstimate, + string dimension) + => tableEstimate.GetProperty(dimension).GetProperty("value").GetInt64(); + + private static void AssertDryRunEstimateMetadata( + JsonElement estimate, + string source, + string confidence) + { + Assert.Equal(source, estimate.GetProperty("source").GetString()); + Assert.Equal(confidence, estimate.GetProperty("confidence").GetString()); + Assert.Empty(estimate.GetProperty("unknown_reasons").EnumerateArray()); + } + [Fact] public void Run_DryRun_WithFiles_ReportsSupportedExtensionRenamePurgeWithoutWriting() {