From 06441057676858d8113cb310c4eeb29511bba2f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 00:25:06 +0900 Subject: [PATCH 1/3] Separate diff telemetry from semantic changes (#4884) --- DEVELOPER_GUIDE.md | 2 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 32 +- changelog.d/unreleased/4884.fixed.md | 19 ++ src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/DiffCommandOptionsParser.cs | 12 + src/CodeIndex/Cli/DiffCommandRunner.cs | 293 +++++++++++++++--- src/CodeIndex/Cli/DiffCursorCodec.cs | 21 +- src/CodeIndex/Cli/DiffResultWriter.cs | 12 + src/CodeIndex/Cli/JsonOutputContracts.cs | 14 +- .../DiffCommandHelpersTests.cs | 11 +- .../CodeIndex.Tests/DiffCommandRunnerTests.cs | 166 +++++++++- 12 files changed, 517 insertions(+), 69 deletions(-) create mode 100644 changelog.d/unreleased/4884.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6c7b67405..c96dc2842 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1685,6 +1685,7 @@ access. | Generated-code filtering metadata | DB-backed discovery `query_context` always reports `include_generated`, `generated_code_policy`, and `generated_file_filter_available`. The `files --count --json` and every JSON `map` summary (including `issue-drafts`) also report `generated_file_count_excluded` and `generated_file_count_excluded_authoritative`. The excluded count is `0` when generated files are included. For a legacy DB without `files.generated` when filtering is requested, the policy is `unavailable`, the count is `null`, and the authoritative/available flags are `false` rather than claiming that an unavailable filter ran; explicit `--include-generated` remains `include` with an authoritative excluded count of `0`. Byte-capped and uncapped raw discovery arrays retain SQLite trust diagnostics even when the query returns no result rows. | | Map scope, depth, and freshness | `map --depth ` applies path, language, test, generated-code, and exclusion filters before aggregating modules by the requested prefix depth. Scoped map output excludes the workspace-global decomposition plan. Workspace HEAD metadata is read in one query from the same SQLite snapshot as the map and remains explicit under `head_freshness`: `scope=workspace`, `indexed_head_source=latest_index` for the current successful index stamp (or `legacy_full_scan` only when it is the fallback), and `legacy_full_scan_head` for the separately labeled compatibility stamp. `issue-drafts` evaluates every scoped file for its thresholds, so `candidate_source=evaluated_scoped_candidates`, candidate counts, group totals, omitted counts, and `truncation.issue_draft_candidates` are candidate-based even though candidate details remain bounded; `truncation.largest_files` is a labeled compatibility alias only. | | `test-extractor` JSON | Machine-readable `test-extractor` success uses a versioned `{"api_version":"1","symbols":[...]}` envelope; the nested symbol objects retain their established property names. `--json` failures use the shared versioned command-error contract. | +| Database diff categories | Every `diff` summary reports `data`, `schema`, `readiness_provenance`, and `volatile_telemetry` entries with `evaluated`, `included`, `different`, `reason_count`, and stable `reasons`. Default `semantic` status excludes volatile telemetry; `--data-only` also excludes readiness/provenance, while `--include-telemetry` explicitly includes volatile run metadata. `summary.difference_reasons` is the complete included reason set and must be non-empty whenever the result is non-identical. | | Compact location envelope | CLI `--format compact` location output uses a versioned envelope with `api_version`, returned `count`, conservative limit-based `truncated` / `truncation` metadata, applied `query_context`, and lightweight `results` rows. | | Grouped search totals | `search --format grouped` derives `total_matches` / `matched_count`, `total_groups`, and `total_files` from the complete bounded query rather than the displayed page. `grouped_match_count` counts rows supplied to returned groups, `emitted_match_count` counts rows left after per-file grouping limits, and `omitted_match_count`, `truncated`, `has_more`, and `continuation_action` describe incomplete output. | | Bounded high-volume responses | `search`, `definition`, `find`, `status`, `hotspots`, `references`, `callers`, `callees`, `symbols`, `files`, `languages`, `impact`, and `map` accept shared bounded-response controls where their schema exposes them. Newly emitted opaque `--cursor ` values bind the offset to the command/query/filter selection and index generation; legacy `response:v1::` cursors remain accepted for transition. Reuse with changed selection or generation fails with restart-required guidance. `search --format compact`, `symbols --format compact`, and `files --format compact` auto-select the bounded contract, while `search --json=array --json-envelope` provides the opt-in array envelope and `languages --json` selects it when paging or `--max-json-bytes` is requested. Existing compact roots and location rows remain compatible while adding shared metadata. Metadata reports `returned_count`, authoritative `total_count` where available, `omitted_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `next_cursor`, `result_stable_at`, `pagination_window_limit`, and `pagination_window_exhausted`. The safety window is 10,000 rows; exhaustion suppresses `next_cursor` rather than returning a cursor that the next request would reject. Pageable commands pass the cursor offset into their database/scan layer instead of serializing an `offset + limit` prefix. `find --all` partial scans encode the next path/line in the opaque cursor so replay continues after the last scanned line. `hotspots` and `impact` page their active primary nested collection as `results`, identify it with `metadata.primary_collection`, and retain scalar/container evidence in `metadata.response_context`; dotted fields such as `callers.path,callers.depth` select that collection and project its rows. The final newline is included in `--max-json-bytes`, and trailing whole rows are removed until the complete envelope fits. `definition` remains metadata-only by default; explicit `--body` content is retained for `body`, `body_content`, or `all`, and suppressed when the projection excludes it. `map --sections` remains its section-level projection, while dotted bounded fields page a selected array section with section-specific totals and scalar projections skip unused ranked arrays. | @@ -5059,6 +5060,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | generated-code filtering metadata | DB-backed discovery の `query_context` は常に `include_generated`、`generated_code_policy`、`generated_file_filter_available` を返します。`files --count --json` と `issue-drafts` を含むすべての JSON `map` summary は、`generated_file_count_excluded` と `generated_file_count_excluded_authoritative` も返します。generated file を含める場合、除外数は `0` です。`files.generated` が無い legacy DB で filter が要求された場合、未実行の filter を実行済みと誤認させないよう、policy は `unavailable`、count は `null`、authoritative / available flag は `false` になります。明示的な `--include-generated` は `include` のままで、authoritative な除外数 `0` を返します。byte cap の有無にかかわらず、raw discovery array は query result row が 0 件でも SQLite trust diagnostics を維持します。 | | map の scope、depth、freshness | `map --depth ` は path、language、test、generated-code、除外条件を適用してから、指定した prefix depth で module を集計します。scope を絞った map output からは workspace 全体向けの decomposition plan を除外します。workspace HEAD metadata は map と同じ SQLite snapshot から 1 query で読み、`head_freshness` に `scope=workspace`、現在の成功 index stamp なら `indexed_head_source=latest_index`(fallback の場合だけ `legacy_full_scan`)、互換用 stamp は別名の `legacy_full_scan_head` として明示します。`issue-drafts` は scope 内の全 file を閾値評価するため、`candidate_source=evaluated_scoped_candidates`、candidate 件数、group 合計、省略数、`truncation.issue_draft_candidates` は candidate 基準になります。`truncation.largest_files` は明示的な互換 alias としてのみ残します。 | | `test-extractor` JSON | 機械可読な `test-extractor` success は versioned `{"api_version":"1","symbols":[...]}` envelope を使い、内側の symbol object は既存の property 名を維持します。`--json` failure は共通の versioned command-error 契約を使います。 | +| database diff category | すべての `diff` summary は `data`、`schema`、`readiness_provenance`、`volatile_telemetry` の entry を返し、各 entry は `evaluated`、`included`、`different`、`reason_count`、stable な `reasons` を持ちます。既定の `semantic` status は volatile telemetry を除外し、`--data-only` は readiness/provenance も除外します。`--include-telemetry` は volatile な実行 metadata を明示的に含めます。`summary.difference_reasons` は判定対象に含まれる完全な reason set であり、non-identical result では必ず空でないことを維持します。 | | compact location envelope | CLI の `--format compact` location output は、`api_version`、返却 `count`、limit 到達を基準にした保守的な `truncated` / `truncation` metadata、適用済み `query_context`、軽量な `results` row を持つ versioned envelope です。 | | grouped search の総数 | `search --format grouped` の `total_matches` / `matched_count`、`total_groups`、`total_files` は、表示 page ではなく上限適用前の query 全体から算出します。`grouped_match_count` は返却 group に渡した row 数、`emitted_match_count` は file ごとの上限適用後に残った row 数を表し、`omitted_match_count`、`truncated`、`has_more`、`continuation_action` が未完了出力を示します。 | | 高ボリューム応答の bounded 契約 | `search`、`definition`、`find`、`status`、`hotspots`、`references`、`callers`、`callees`、`symbols`、`files`、`languages`、`impact`、`map` は、それぞれの schema が公開する共通 bounded-response control に対応します。新しく発行する opaque な `--cursor ` は offset を command / query / filter と index generation に束縛し、移行用に legacy の `response:v1::` も受理します。選択条件または generation を変えて再利用すると restart-required の案内付きで失敗します。`search --format compact`、`symbols --format compact`、`files --format compact` は bounded 契約を自動選択し、`search --json=array --json-envelope` は opt-in の array envelope、`languages --json` は paging または `--max-json-bytes` 指定時に同じ契約を使います。既存 compact の root と location row は維持したまま共通 metadata を追加します。metadata は `returned_count`、取得可能な場合は authoritative な `total_count`、`omitted_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`next_cursor`、`result_stable_at`、`pagination_window_limit`、`pagination_window_exhausted` を返します。safety window は 10,000 row で、上限到達時は次の request が拒否する cursor を返さず `next_cursor` を抑止します。pageable command は `offset + limit` 件を serialize せず、cursor offset を database / scan layer へ渡します。`find --all` の partial scan cursor は次の path / line を保持し、再利用時は最後に scan した line の次から継続します。`hotspots` と `impact` は active な主要 nested collection を `results` としてページングし、`metadata.primary_collection` でその名前を示し、scalar / container evidence は `metadata.response_context` に保持します。`callers.path,callers.depth` のような dotted field で collection と row field を同時に選べます。`--max-json-bytes` は最後の改行を含み、完全な envelope が収まるまで末尾の完全な row を省略します。`definition` は既定で metadata-only のままで、明示的な `--body` は `body`、`body_content`、`all` で保持し、それ以外の projection では materialize 前に抑止します。`map --sections` は section-level projection として残り、dotted な bounded field は選択した array section を section 固有の総件数付きでページングし、scalar projection は不要な ranking array を構築しません。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index dab3aac23..95b7e75dc 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -686,6 +686,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - Keep tests deterministic. Do not depend on machine-global git config, locale-specific output, or ambient files. - Prefer `ManualTimeProvider` for fake clocks and `TestDeterminism.CreateRandom` for randomized fixture input so repeated test runs replay the same timeline and data. Use `TestDeterminism.WaitUntilAsync` or the synchronous `WaitUntil` for bounded polling/eventual assertions instead of local `Task.Delay` loops or fixed sleeps. Use `AssertConditionRemainsTrue` for short absence/stability observations, and `TestDeterminism.RunConcurrentlyAsync` when a test needs workers to start from the same gate. - Prefer small fixtures and explicit assertions over broad snapshot-style checks. The one narrow exception is the `--json` output contract harness (`JsonOutputSnapshotTests`), which pins the full field shape on purpose — see "JSON `--json` output snapshots" below. +- Database-diff category coverage should reuse one semantic-equivalent database pair for volatile timestamp/duration/mode/byte-counter drift, readiness/provenance drift, default semantic mode, `--data-only`, and `--include-telemetry`. Assert the stable category and reason codes in both included and excluded modes; keep content, graph, schema, and legacy-metadata assertions on their closest existing fixtures. - For cross-language extractor budget tests, exceed the shared boundary by the smallest value that triggers truncation; do not add arbitrary padding independently per language. - Dense C# primary-constructor and Java record runaway guards use 20,000 and 13,000 declarations respectively, assert every extracted member plus both endpoints, and share a ten-second ceiling so coverage instrumentation retains the original CI headroom; larger arbitrary fixtures repeat the same linear path without covering another boundary. - TypeScript and Swift alias-expansion runaway guards use 500 alias uses, retain first/last expanded-reference assertions, and share a five-second ceiling; keep the paired language fixtures aligned. @@ -1788,6 +1789,7 @@ background の log / metrics sink は、sleep や狭い stopwatch 閾値では - immediate help routing の test は rendered usage と終了コードを併せて検証し、通常実行なら追加入力を必要とする nested command を含めて help が dispatch しないことを証明し、対応する公開 alias の受理と内部 usage key の拒否も確認する。 - JSON 出力は生文字列比較ではなく `JsonDocument` で解析して検証する。 - 詳細 database diff の coverage では、record の materialize を指定 JSON budget と最大 1 件の candidate record 以内に抑え、完全な UTF-8 record 境界でのみ停止し、deterministic な差分 sequence が変化した後の cursor を拒否し、前進できない continuation を返さないことを維持する。limit 0 と最初の record が大きすぎる page の recovery 案内、埋め込み import dry-run 比較で利用不能な cursor / replay metadata を抑止することに加え、非詳細 JSON と詳細 text 出力で有効な `--offset` continuation を検証する。 +- database diff の category coverage では、semantic に同等な1組の database を、volatile な timestamp / duration / mode / byte counter の drift、readiness/provenance drift、既定の semantic mode、`--data-only`、`--include-telemetry` で再利用する。判定対象に含む mode と除外する mode の両方で stable な category / reason code を検証し、content、graph、schema、legacy metadata の assertion は最も近い既存 fixture に維持する。 - `files` / `map` の `--exclude-tests` に関する cross-command invariant は、1 つの seed 済み fixture で維持する。暗黙の本番ソース preset と明示的な `--path` の両方について、`map.file_count` と parse 済み `files` row 数を比較する。 - JSON failure contract のテストでは、stdout に parse 可能な version 付き error object が 1 件だけあること、stderr が空であること、documented exit code が維持されること、`status` / `error_code` が失敗を識別することを検証する。 - auth-token recipe の ranking coverage では、高 signal な credential fixture と comment、regex 定義、構造的な `CancellationToken` / syntax-token decoy を対にし、bearer / authorization、GitHub / API / access-token、token-secret の各 child query について明示的な top-N precision を検証する。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0aa420f68..83cdf85ed 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -534,12 +534,25 @@ Use `cdidx diff --detailed --json` to verify the restored index. Database identity is based on semantic index content: reference-line links are compared by their indexed path, line, and context rather than SQLite surrogate row IDs, so equivalent databases remain identical after rows are rehydrated. +Every diff summary classifies observed changes as `data`, `schema`, +`readiness_provenance`, or `volatile_telemetry`. The default `semantic` mode +includes the first three categories in `status` / `identical` while observing +but excluding volatile run telemetry, so a no-op reindex that changes only +timestamps, duration, mode, or byte counters still compares identical. +`--data-only` also excludes readiness/provenance from the result status, while +`--include-telemetry` explicitly includes volatile telemetry; these two flags +cannot be combined. `summary.categories[]` reports `evaluated`, `included`, +`different`, and stable `reasons` for every category, and +`summary.difference_reasons` lists every included reason that made the result +non-identical. Human output prints the same category reasons. Detailed JSON returns one deterministic `records` sequence. Each record names its `area` and `side`, carries a stable `identity_sha256`, and exposes named `fields` instead of an opaque encoded row. By default, text fields and database paths are redacted to SHA-256 and UTF-8 byte-length metadata; source text is returned only when `--include-content` is explicitly combined with -`--detailed --json`. +`--detailed --json`. Readiness/provenance metadata records remain visible for +diagnosis even when `--data-only` excludes that category from status. Volatile +telemetry records are emitted only with `--include-telemetry`. Detailed JSON is capped at 1 MiB by default. Every JSON mode accepts `--max-json-bytes ` (4096 through 16777216) for a caller-controlled @@ -3805,12 +3818,25 @@ SHA-256 と UTF-8 byte length metadata として表現されます。record の 復元した index の確認には `cdidx diff --detailed --json` を使います。 database の同一性は semantic index content に基づきます。reference-line link は SQLite の surrogate row ID ではなく indexed path、line、context で比較されるため、row が再構築されても -意味的に同等な database は identical のままです。詳細 JSON は deterministic な単一の +意味的に同等な database は identical のままです。 +すべての diff summary は観測した変更を `data`、`schema`、`readiness_provenance`、 +`volatile_telemetry` に分類します。既定の `semantic` mode は最初の3カテゴリを +`status` / `identical` の判定に含め、volatile な実行 telemetry は観測しつつ除外します。 +そのため、timestamp、duration、mode、byte counter だけが変わる no-op reindex は +identical と比較されます。`--data-only` は readiness/provenance も結果 status から除外し、 +`--include-telemetry` は volatile telemetry を明示的に判定へ含めます。この2 flag は +同時指定できません。`summary.categories[]` は各カテゴリの `evaluated`、`included`、 +`different`、stable な `reasons` を報告し、`summary.difference_reasons` は +non-identical の原因になった判定対象の理由をすべて列挙します。human output も同じ +category reason を表示します。 +詳細 JSON は deterministic な単一の `records` sequence を返します。各 record は `area` と `side` を明示し、stable な `identity_sha256` と、opaque な encoded row ではなく名前付きの `fields` を持ちます。 既定では text field と database path を SHA-256 と UTF-8 byte length metadata に redact します。source text を返すには `--detailed --json` とともに -`--include-content` を明示的に指定してください。 +`--include-content` を明示的に指定してください。readiness/provenance metadata record は +`--data-only` で status 判定から除外した場合も診断用に表示されます。volatile telemetry +record は `--include-telemetry` を指定した場合だけ出力されます。 詳細 JSON は既定で 1 MiB に制限されます。すべての JSON mode で、caller が output 全体の UTF-8 budget を指定するための `--max-json-bytes `(4096 以上 16777216 以下) diff --git a/changelog.d/unreleased/4884.fixed.md b/changelog.d/unreleased/4884.fixed.md new file mode 100644 index 000000000..482c0ae7d --- /dev/null +++ b/changelog.d/unreleased/4884.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4884 +affected: + - src/CodeIndex/Cli/DiffCommandRunner.cs + - src/CodeIndex/Cli/DiffCommandOptionsParser.cs + - src/CodeIndex/Cli/DiffResultWriter.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - USER_GUIDE.md +--- + +## English + +- **Database diff now separates semantic changes from volatile index-run telemetry (#4884)** — `diff` reports stable data, schema, readiness/provenance, and volatile-telemetry categories with reasons in human and JSON output. Telemetry-only no-op reindexes compare equal by default, `--data-only` excludes readiness/provenance from status, and `--include-telemetry` explicitly opts into volatile run metadata. + +## 日本語 + +- **database diff が semantic な変更と volatile な index-run telemetry を分離するようになりました (#4884)** — `diff` は data、schema、readiness/provenance、volatile telemetry の stable な category と reason を human / JSON 出力で報告します。telemetry だけが変わる no-op reindex は既定で equal になり、`--data-only` は readiness/provenance を status 判定から除外し、`--include-telemetry` は volatile な実行 metadata を明示的に判定へ含めます。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 22e8b6755..6fc89c3ef 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -126,7 +126,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("db-checkpoints", "cdidx db checkpoints --list|--delete |--prune [--keep ] [--dry-run] [--db ] [--json]"), ("db-restore", "cdidx db restore [--dry-run] [--no-backup] [--db ] [--json]"), ("db-restore-backups", "cdidx db restore-backups --list|--prune [--keep ]|--restore [--dry-run] [--no-backup] [--db ] [--json]"), - ("diff", $"cdidx diff [--json] [--summary-only] [--detailed] [--include-content] [--max-json-bytes ] [--limit ] [--offset |--cursor ]"), + ("diff", $"cdidx diff [--json] [--summary-only] [--detailed] [--data-only|--include-telemetry] [--include-content] [--max-json-bytes ] [--limit ] [--offset |--cursor ]"), ("report", "cdidx report --output [--overwrite] [--db ] [--json] [--redact-paths] [--log-lines ] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]"), ("impact", "cdidx impact |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--exact-name] [--count] [--with-paths]"), diff --git a/src/CodeIndex/Cli/DiffCommandOptionsParser.cs b/src/CodeIndex/Cli/DiffCommandOptionsParser.cs index 750994f77..50364bb8a 100644 --- a/src/CodeIndex/Cli/DiffCommandOptionsParser.cs +++ b/src/CodeIndex/Cli/DiffCommandOptionsParser.cs @@ -11,6 +11,8 @@ internal static DiffCommandOptions Parse(string[] args, int maxLimit) var detailed = false; var summaryOnly = false; var includeContent = false; + var dataOnly = false; + var includeTelemetry = false; var limit = DefaultLimit; var offset = 0; var offsetExplicit = false; @@ -38,6 +40,12 @@ internal static DiffCommandOptions Parse(string[] args, int maxLimit) case "--include-content": includeContent = true; break; + case "--data-only": + dataOnly = true; + break; + case "--include-telemetry": + includeTelemetry = true; + break; case "--max-json-bytes" when i + 1 < args.Length: if (!int.TryParse(args[++i], out var parsedMaxJsonBytes) || parsedMaxJsonBytes < DiffCommandRunner.MinDiffJsonBytes) @@ -99,6 +107,8 @@ internal static DiffCommandOptions Parse(string[] args, int maxLimit) parseError = "diff requires exactly two database paths"; if (parseError is null && includeContent && (!detailed || !json || summaryOnly)) parseError = "--include-content requires --detailed --json and cannot be combined with --summary-only"; + if (parseError is null && dataOnly && includeTelemetry) + parseError = "--data-only cannot be combined with --include-telemetry"; if (parseError is null && maxJsonBytes.HasValue && !(json || summaryOnly)) parseError = "--max-json-bytes is only supported with JSON diff output"; if (parseError is null && cursor is not null && (!detailed || !json || summaryOnly)) @@ -125,6 +135,8 @@ internal static DiffCommandOptions Parse(string[] args, int maxLimit) Detailed = detailed, SummaryOnly = summaryOnly, IncludeContent = includeContent, + DataOnly = dataOnly, + IncludeTelemetry = includeTelemetry, Limit = limit, Offset = offset, OffsetExplicit = offsetExplicit, diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index 79f0a7b45..1b05b391a 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -362,7 +362,7 @@ ORDER BY file_issues.message """; - private const string MetaRowsSql = """ + private const string ContractMetaRowsSql = """ SELECT key, value @@ -381,7 +381,7 @@ ORDER BY value """; - private const string OperationalMetaRowsSql = """ + private const string ProvenanceMetaRowsSql = """ SELECT key, value @@ -393,10 +393,8 @@ FROM codeindex_meta OR key = 'indexed_head_commit_branch' OR key = 'indexed_head_sha' OR key = 'indexed_head_branch' - OR key = 'indexed_head_timestamp' OR key = 'commit_scoped_fresh_head_sha' OR key = 'workspace_path_case_sensitive' - OR key LIKE 'last_index_run_%' OR key = 'unknown_extension_file_count' OR key = 'unknown_extension_file_paths_json' OR key = 'unknown_extension_files_truncated' @@ -410,6 +408,19 @@ ORDER BY value """; + private const string VolatileTelemetryMetaRowsSql = """ + SELECT + key, + value + FROM codeindex_meta + WHERE + key = 'indexed_head_timestamp' + OR key LIKE 'last_index_run_%' + ORDER BY + key, + value + """; + private const string LegacyReferenceRowsSql = """ SELECT COALESCE(files.path, ''), @@ -454,29 +465,13 @@ private static DiffJsonResult BuildDiff( CliJsonSerializerContext? materializationJsonContext, CancellationToken cancellationToken) { - var summary = new DiffSummaryJsonResult( - left.FileCount, - right.FileCount, - right.FileCount - left.FileCount, - left.SymbolCount, - right.SymbolCount, - right.SymbolCount - left.SymbolCount, - left.ReferenceCount, - right.ReferenceCount, - right.ReferenceCount - left.ReferenceCount, - left.SchemaVersion, - right.SchemaVersion, - left.SchemaVersion == right.SchemaVersion); - var filesOnlyInLeft = new List(); var filesOnlyInRight = new List(); var diagnostics = new List(); var hasMore = false; - var identical = - summary.SchemaVersionsEqual && - summary.FileCountDelta == 0 && - summary.SymbolCountDelta == 0 && - summary.ReferenceCountDelta == 0; + var dataReasons = new List(); + var readinessProvenanceReasons = new List(); + var telemetryReasons = new List(); using var leftConnection = OpenReadOnlyConnection(options.LeftDb!); using var rightConnection = OpenReadOnlyConnection(options.RightDb!); @@ -493,7 +488,6 @@ private static DiffJsonResult BuildDiff( var fileDiff = DiffOrderedStrings(leftConnection, rightConnection, FilePathRowsSql, options.Limit, options.Offset, cancellationToken); filesOnlyInLeft = fileDiff.OnlyInLeft; filesOnlyInRight = fileDiff.OnlyInRight; - identical = identical && fileDiff.Equal; hasMore |= fileDiff.HasMore; AddPagingDiagnostic(diagnostics, fileDiff.Omitted, fileDiff.HasMore, "file differences", options); } @@ -505,15 +499,27 @@ private static DiffJsonResult BuildDiff( options.Offset, options.Limit, options.IncludeContent, + options.DataOnly, + options.IncludeTelemetry, options.Json ? options.MaxJsonBytes ?? DefaultDiffJsonBytes : null, materializationJsonContext) : null; + + bool fileRowsEqual; + bool symbolRowsEqual; + bool referenceRowsEqual; + bool chunkRowsEqual; + bool referenceLineRowsEqual; + bool fileIssueRowsEqual; + bool contractMetadataEqual; + bool provenanceMetadataEqual; + bool volatileTelemetryEqual; if (collector is not null) { cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + fileRowsEqual = CollectOrderedRows( leftConnection, rightConnection, FileRowsSql, @@ -524,7 +530,7 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + symbolRowsEqual = CollectOrderedRows( leftConnection, rightConnection, leftSymbolRowsSql, @@ -535,7 +541,7 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + referenceRowsEqual = CollectOrderedRows( leftConnection, rightConnection, leftReferenceRowsSql, @@ -546,7 +552,7 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + chunkRowsEqual = CollectOrderedRows( leftConnection, rightConnection, ChunkRowsSql, @@ -557,7 +563,7 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + referenceLineRowsEqual = CollectOrderedRows( leftConnection, rightConnection, ReferenceLineRowsSql, @@ -568,7 +574,7 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectOrderedRows( + fileIssueRowsEqual = CollectOrderedRows( leftConnection, rightConnection, FileIssueRowsSql, @@ -579,37 +585,136 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - identical &= CollectMetadataRows( + contractMetadataEqual = CollectMetadataRows( leftConnection, rightConnection, - MetaRowsSql, + ContractMetaRowsSql, "contract_metadata", collector, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - _ = CollectMetadataRows( + provenanceMetadataEqual = CollectMetadataRows( leftConnection, rightConnection, - OperationalMetaRowsSql, - "operational_metadata", + ProvenanceMetaRowsSql, + "readiness_provenance_metadata", collector, cancellationToken); - } - if (identical && collector is null) + cancellationToken.ThrowIfCancellationRequested(); + volatileTelemetryEqual = options.IncludeTelemetry + ? CollectMetadataRows( + leftConnection, + rightConnection, + VolatileTelemetryMetaRowsSql, + "volatile_telemetry_metadata", + collector, + cancellationToken) + : RowsEqual( + leftConnection, + rightConnection, + VolatileTelemetryMetaRowsSql, + cancellationToken); + } + else { cancellationToken.ThrowIfCancellationRequested(); - identical = - RowsEqual(leftConnection, rightConnection, FileRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, ChunkRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, ReferenceLineRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, FileIssueRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, MetaRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, leftSymbolRowsSql, rightSymbolRowsSql, cancellationToken) && - RowsEqual(leftConnection, rightConnection, leftReferenceRowsSql, rightReferenceRowsSql, cancellationToken); + fileRowsEqual = RowsEqual(leftConnection, rightConnection, FileRowsSql, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + symbolRowsEqual = RowsEqual( + leftConnection, + rightConnection, + leftSymbolRowsSql, + rightSymbolRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + referenceRowsEqual = RowsEqual( + leftConnection, + rightConnection, + leftReferenceRowsSql, + rightReferenceRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + chunkRowsEqual = RowsEqual(leftConnection, rightConnection, ChunkRowsSql, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + referenceLineRowsEqual = RowsEqual( + leftConnection, + rightConnection, + ReferenceLineRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + fileIssueRowsEqual = RowsEqual( + leftConnection, + rightConnection, + FileIssueRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + contractMetadataEqual = RowsEqual( + leftConnection, + rightConnection, + ContractMetaRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + provenanceMetadataEqual = RowsEqual( + leftConnection, + rightConnection, + ProvenanceMetaRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + volatileTelemetryEqual = RowsEqual( + leftConnection, + rightConnection, + VolatileTelemetryMetaRowsSql, + cancellationToken); } + AddDifferenceReason(dataReasons, fileRowsEqual, "file_rows_changed"); + AddDifferenceReason(dataReasons, symbolRowsEqual, "symbol_rows_changed"); + AddDifferenceReason(dataReasons, referenceRowsEqual, "reference_rows_changed"); + AddDifferenceReason(dataReasons, chunkRowsEqual, "chunk_rows_changed"); + AddDifferenceReason(dataReasons, referenceLineRowsEqual, "reference_line_rows_changed"); + AddDifferenceReason(dataReasons, fileIssueRowsEqual, "file_issue_rows_changed"); + AddDifferenceReason( + readinessProvenanceReasons, + contractMetadataEqual, + "contract_metadata_changed"); + AddDifferenceReason( + readinessProvenanceReasons, + provenanceMetadataEqual, + "provenance_metadata_changed"); + AddDifferenceReason( + telemetryReasons, + volatileTelemetryEqual, + "volatile_telemetry_metadata_changed"); + + var categories = BuildDifferenceCategories( + dataReasons, + schemaReasons: [], + readinessProvenanceReasons, + telemetryReasons, + options, + evaluated: true); + var differenceReasons = BuildIncludedDifferenceReasons(categories); + var identical = differenceReasons.Count == 0; + var summary = new DiffSummaryJsonResult( + left.FileCount, + right.FileCount, + right.FileCount - left.FileCount, + left.SymbolCount, + right.SymbolCount, + right.SymbolCount - left.SymbolCount, + left.ReferenceCount, + right.ReferenceCount, + right.ReferenceCount - left.ReferenceCount, + left.SchemaVersion, + right.SchemaVersion, + true, + GetComparisonMode(options), + differenceReasons.Count, + differenceReasons, + categories); + List? records = null; long? totalCount = null; int? returnedCount = null; @@ -700,6 +805,60 @@ private static DiffJsonResult BuildDiff( TruncationReason: truncationReason); } + private static void AddDifferenceReason(List reasons, bool equal, string reason) + { + if (!equal) + reasons.Add(reason); + } + + private static List BuildDifferenceCategories( + List dataReasons, + List schemaReasons, + List readinessProvenanceReasons, + List telemetryReasons, + DiffCommandOptions options, + bool evaluated) + => + [ + BuildDifferenceCategory("data", evaluated, included: true, dataReasons), + BuildDifferenceCategory("schema", evaluated, included: true, schemaReasons), + BuildDifferenceCategory( + "readiness_provenance", + evaluated, + included: !options.DataOnly, + readinessProvenanceReasons), + BuildDifferenceCategory( + "volatile_telemetry", + evaluated, + included: options.IncludeTelemetry, + telemetryReasons), + ]; + + private static DiffCategorySummaryJsonResult BuildDifferenceCategory( + string category, + bool evaluated, + bool included, + List reasons) + => new( + category, + evaluated, + included, + reasons.Count > 0, + reasons.Count, + reasons); + + private static List BuildIncludedDifferenceReasons( + List categories) + => categories + .Where(category => category.Evaluated && category.Included && category.Different) + .SelectMany(category => category.Reasons.Select(reason => $"{category.Category}:{reason}")) + .ToList(); + + private static string GetComparisonMode(DiffCommandOptions options) + => options.IncludeTelemetry + ? "semantic_with_telemetry" + : options.DataOnly ? "data_only" : "semantic"; + private static void AddPagingDiagnostic( List diagnostics, bool omitted, @@ -720,6 +879,22 @@ private static void AddPagingDiagnostic( private static DiffJsonResult BuildSchemaMismatchDiff(DiffDbHeader left, DiffDbHeader right, DiffCommandOptions options) { + var categories = new List + { + BuildDifferenceCategory("data", evaluated: false, included: true, []), + BuildDifferenceCategory("schema", evaluated: true, included: true, ["schema_version_changed"]), + BuildDifferenceCategory( + "readiness_provenance", + evaluated: false, + included: !options.DataOnly, + []), + BuildDifferenceCategory( + "volatile_telemetry", + evaluated: false, + included: options.IncludeTelemetry, + []), + }; + var differenceReasons = BuildIncludedDifferenceReasons(categories); var summary = new DiffSummaryJsonResult( left.FileCount, right.FileCount, @@ -732,9 +907,18 @@ private static DiffJsonResult BuildSchemaMismatchDiff(DiffDbHeader left, DiffDbH right.ReferenceCount - left.ReferenceCount, left.SchemaVersion, right.SchemaVersion, - false); + false, + GetComparisonMode(options), + differenceReasons.Count, + differenceReasons, + categories); var selectionFingerprint = options.Detailed && options.EmitCursorMetadata - ? DiffCursorCodec.CreateSelectionFingerprint(options.LeftDb!, options.RightDb!, options.IncludeContent) + ? DiffCursorCodec.CreateSelectionFingerprint( + options.LeftDb!, + options.RightDb!, + options.IncludeContent, + options.DataOnly, + options.IncludeTelemetry) : null; var currentCursor = selectionFingerprint is null || !options.EmitCursorMetadata ? null @@ -1371,6 +1555,10 @@ internal static DiffReplayJsonResult BuildReplayMetadata( ]; if (options.IncludeContent) nextPageArguments.Add("--include-content"); + if (options.DataOnly) + nextPageArguments.Add("--data-only"); + if (options.IncludeTelemetry) + nextPageArguments.Add("--include-telemetry"); var replayMaxJsonBytes = effectiveMaxJsonBytes ?? options.MaxJsonBytes; if (replayMaxJsonBytes.HasValue) { @@ -1539,6 +1727,8 @@ internal DiffRecordPageCollector( int offset, int limit, bool includeContent, + bool dataOnly, + bool includeTelemetry, int? materializationByteBudget, CliJsonSerializerContext? materializationJsonContext) { @@ -1548,7 +1738,12 @@ internal DiffRecordPageCollector( _materializationByteBudget = materializationByteBudget; _materializationJsonContext = materializationJsonContext ?? CliJsonSerializerContext.Default; - _selectionHash = DiffCursorCodec.CreateSelectionHash(leftDb, rightDb, includeContent); + _selectionHash = DiffCursorCodec.CreateSelectionHash( + leftDb, + rightDb, + includeContent, + dataOnly, + includeTelemetry); } internal List Records { get; } = []; @@ -1638,6 +1833,8 @@ internal sealed class DiffCommandOptions public bool Detailed { get; init; } public bool SummaryOnly { get; init; } public bool IncludeContent { get; init; } + public bool DataOnly { get; init; } + public bool IncludeTelemetry { get; init; } public bool ShowHelp { get; init; } public int Limit { get; init; } = 20; public int Offset { get; init; } diff --git a/src/CodeIndex/Cli/DiffCursorCodec.cs b/src/CodeIndex/Cli/DiffCursorCodec.cs index e0a3be1b6..037cb6b95 100644 --- a/src/CodeIndex/Cli/DiffCursorCodec.cs +++ b/src/CodeIndex/Cli/DiffCursorCodec.cs @@ -9,19 +9,34 @@ internal static class DiffCursorCodec internal const string Prefix = "diff:v1:"; internal const int MaxCursorLength = 512; - internal static string CreateSelectionFingerprint(string leftDb, string rightDb, bool includeContent) + internal static string CreateSelectionFingerprint( + string leftDb, + string rightDb, + bool includeContent, + bool dataOnly, + bool includeTelemetry) { - using var hash = CreateSelectionHash(leftDb, rightDb, includeContent); + using var hash = CreateSelectionHash(leftDb, rightDb, includeContent, dataOnly, includeTelemetry); return CompleteSelectionFingerprint(hash); } - internal static IncrementalHash CreateSelectionHash(string leftDb, string rightDb, bool includeContent) + internal static IncrementalHash CreateSelectionHash( + string leftDb, + string rightDb, + bool includeContent, + bool dataOnly, + bool includeTelemetry) { var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); AppendSelectionPart(hash, "diff-record-selection:v1"); AppendSelectionPart(hash, leftDb); AppendSelectionPart(hash, rightDb); AppendSelectionPart(hash, includeContent ? "include-content" : "redacted"); + AppendSelectionPart( + hash, + includeTelemetry + ? "semantic-with-telemetry" + : dataOnly ? "data-only" : "semantic"); return hash; } diff --git a/src/CodeIndex/Cli/DiffResultWriter.cs b/src/CodeIndex/Cli/DiffResultWriter.cs index 554e9a754..1083f0a32 100644 --- a/src/CodeIndex/Cli/DiffResultWriter.cs +++ b/src/CodeIndex/Cli/DiffResultWriter.cs @@ -168,10 +168,12 @@ private static void WriteText(DiffJsonResult result, DiffCommandOptions options) Console.WriteLine($" left : {result.LeftDb}"); Console.WriteLine($" right : {result.RightDb}"); Console.WriteLine($" status : {result.Status}"); + Console.WriteLine($" mode : {result.Summary.ComparisonMode}"); Console.WriteLine($" schema : {result.Summary.LeftSchemaVersion} -> {result.Summary.RightSchemaVersion}"); Console.WriteLine($" files : {result.Summary.LeftFileCount} -> {result.Summary.RightFileCount} ({FormatDelta(result.Summary.FileCountDelta)})"); Console.WriteLine($" symbols: {result.Summary.LeftSymbolCount} -> {result.Summary.RightSymbolCount} ({FormatDelta(result.Summary.SymbolCountDelta)})"); Console.WriteLine($" refs : {result.Summary.LeftReferenceCount} -> {result.Summary.RightReferenceCount} ({FormatDelta(result.Summary.ReferenceCountDelta)})"); + WriteDifferenceCategories(result.Summary.Categories); if (result.Offset > 0) Console.WriteLine($" page : offset {result.Offset}, limit {result.Limit}"); @@ -194,6 +196,16 @@ private static void WriteList(string label, List values) Console.WriteLine($" - {value}"); } + private static void WriteDifferenceCategories(List categories) + { + foreach (var category in categories.Where(category => category.Different)) + { + var disposition = category.Included ? "included" : "excluded"; + Console.WriteLine( + $" {category.Category} ({disposition}): {string.Join(", ", category.Reasons)}"); + } + } + private static void WriteRecords(List records) { if (records.Count == 0) diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 7271b9bb1..e9be53a4e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -469,7 +469,19 @@ internal sealed record DiffSummaryJsonResult( [property: JsonPropertyName("reference_count_delta")] long ReferenceCountDelta, [property: JsonPropertyName("left_schema_version")] long LeftSchemaVersion, [property: JsonPropertyName("right_schema_version")] long RightSchemaVersion, - [property: JsonPropertyName("schema_versions_equal")] bool SchemaVersionsEqual); + [property: JsonPropertyName("schema_versions_equal")] bool SchemaVersionsEqual, + [property: JsonPropertyName("comparison_mode")] string ComparisonMode, + [property: JsonPropertyName("difference_reason_count")] int DifferenceReasonCount, + [property: JsonPropertyName("difference_reasons")] List DifferenceReasons, + [property: JsonPropertyName("categories")] List Categories); + +internal sealed record DiffCategorySummaryJsonResult( + [property: JsonPropertyName("category")] string Category, + [property: JsonPropertyName("evaluated")] bool Evaluated, + [property: JsonPropertyName("included")] bool Included, + [property: JsonPropertyName("different")] bool Different, + [property: JsonPropertyName("reason_count")] int ReasonCount, + [property: JsonPropertyName("reasons")] List Reasons); internal sealed record DiffJsonResult( [property: JsonPropertyName("status")] string Status, diff --git a/tests/CodeIndex.Tests/DiffCommandHelpersTests.cs b/tests/CodeIndex.Tests/DiffCommandHelpersTests.cs index 0706807cd..eaf19710b 100644 --- a/tests/CodeIndex.Tests/DiffCommandHelpersTests.cs +++ b/tests/CodeIndex.Tests/DiffCommandHelpersTests.cs @@ -95,7 +95,16 @@ private static DiffJsonResult CreateDiffResult() -1, 8, 8, - true), + true, + "semantic", + 1, + ["data:file_rows_changed"], + [ + new("data", true, true, true, 1, ["file_rows_changed"]), + new("schema", true, true, false, 0, []), + new("readiness_provenance", true, true, false, 0, []), + new("volatile_telemetry", true, false, false, 0, []), + ]), ["src/Left.cs"], ["src/Right.cs"], null, diff --git a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs index 3c18943dc..78bbc4315 100644 --- a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs @@ -29,8 +29,10 @@ public void Run_JsonDiffReportsSyntheticDatabaseDrift_Issue1724() var rightDb = SeedDb(rightRoot, includeExtraFile: true); var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--json", "--limit", "5"]); + var (textExitCode, textOutput) = RunWithCapturedOut([leftDb, rightDb, "--limit", "5"]); Assert.Equal(1, exitCode); + Assert.Equal(1, textExitCode); using var document = JsonDocument.Parse(output); var root = document.RootElement; Assert.Equal("different", root.GetProperty("status").GetString()); @@ -41,6 +43,13 @@ public void Run_JsonDiffReportsSyntheticDatabaseDrift_Issue1724() Assert.Contains( root.GetProperty("files_only_in_right").EnumerateArray(), item => item.GetString() == "src/Extra.cs"); + var summary = root.GetProperty("summary"); + Assert.Contains( + "data:file_rows_changed", + summary.GetProperty("difference_reasons").EnumerateArray().Select(item => item.GetString())); + Assert.Contains("mode : semantic", textOutput, StringComparison.Ordinal); + Assert.Contains("data (included):", textOutput, StringComparison.Ordinal); + Assert.Contains("file_rows_changed", textOutput, StringComparison.Ordinal); } finally { @@ -183,6 +192,113 @@ public void ParseArgs_JsonBudgetAndContentFlagsEnforceDetailedContract_Issue4859 contentWithoutDetailedJson.ParseError); } + [Fact] + public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() + { + var leftRoot = TestProjectHelper.CreateTempProject("cdidx_diff_telemetry_left"); + var rightRoot = TestProjectHelper.CreateTempProject("cdidx_diff_telemetry_right"); + try + { + var leftDb = SeedDb(leftRoot, includeExtraFile: false); + var rightDb = SeedDb(rightRoot, includeExtraFile: false); + var sharedProjectRoot = Path.GetFullPath(leftRoot); + SetMeta(leftDb, DbContext.IndexedProjectRootMetaKey, sharedProjectRoot); + SetMeta(rightDb, DbContext.IndexedProjectRootMetaKey, sharedProjectRoot); + SetMeta(rightDb, "indexed_head_timestamp", "2026-07-29T01:02:03Z"); + SetMeta(rightDb, "last_index_run_started_at", "2026-07-29T01:02:00Z"); + SetMeta(rightDb, "last_index_run_duration_ms", "123"); + SetMeta(rightDb, "last_index_run_mode", "incremental"); + SetMeta(rightDb, "last_index_run_bytes_read", "0"); + + var (semanticExitCode, semanticOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only"]); + var (detailedExitCode, detailedOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--limit", "20"]); + var (telemetryExitCode, telemetryOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only", "--include-telemetry"]); + var (telemetryDetailedExitCode, telemetryDetailedOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--include-telemetry", "--limit", "20"]); + + Assert.Equal(0, semanticExitCode); + Assert.Equal(0, detailedExitCode); + using (var semanticDocument = JsonDocument.Parse(semanticOutput)) + { + var semantic = semanticDocument.RootElement; + Assert.True(semantic.GetProperty("identical").GetBoolean()); + Assert.Equal("semantic", semantic.GetProperty("summary").GetProperty("comparison_mode").GetString()); + Assert.Equal(0, semantic.GetProperty("summary").GetProperty("difference_reason_count").GetInt32()); + var telemetry = GetCategory(semantic, "volatile_telemetry"); + Assert.True(telemetry.GetProperty("different").GetBoolean()); + Assert.False(telemetry.GetProperty("included").GetBoolean()); + Assert.Equal( + "volatile_telemetry_metadata_changed", + Assert.Single(telemetry.GetProperty("reasons").EnumerateArray()).GetString()); + } + using (var detailedDocument = JsonDocument.Parse(detailedOutput)) + { + Assert.Empty(GetRecords(detailedDocument.RootElement, "volatile_telemetry_metadata")); + } + + Assert.Equal(1, telemetryExitCode); + Assert.Equal(1, telemetryDetailedExitCode); + using (var telemetryDocument = JsonDocument.Parse(telemetryOutput)) + { + var telemetry = telemetryDocument.RootElement; + Assert.False(telemetry.GetProperty("identical").GetBoolean()); + Assert.Equal( + "semantic_with_telemetry", + telemetry.GetProperty("summary").GetProperty("comparison_mode").GetString()); + Assert.Contains( + "volatile_telemetry:volatile_telemetry_metadata_changed", + telemetry.GetProperty("summary").GetProperty("difference_reasons") + .EnumerateArray() + .Select(item => item.GetString())); + } + using (var telemetryDetailedDocument = JsonDocument.Parse(telemetryDetailedOutput)) + { + Assert.Equal( + 5, + GetRecords(telemetryDetailedDocument.RootElement, "volatile_telemetry_metadata").Count); + } + + SetMeta(leftDb, "hotspot_family_version", "left-readiness"); + SetMeta(rightDb, "hotspot_family_version", "right-readiness"); + var (readinessExitCode, readinessOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only"]); + var (dataOnlyExitCode, dataOnlyOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only", "--data-only"]); + + Assert.Equal(1, readinessExitCode); + using (var readinessDocument = JsonDocument.Parse(readinessOutput)) + { + Assert.Contains( + "readiness_provenance:contract_metadata_changed", + readinessDocument.RootElement.GetProperty("summary").GetProperty("difference_reasons") + .EnumerateArray() + .Select(item => item.GetString())); + } + Assert.Equal(0, dataOnlyExitCode); + using (var dataOnlyDocument = JsonDocument.Parse(dataOnlyOutput)) + { + var dataOnly = dataOnlyDocument.RootElement; + Assert.True(dataOnly.GetProperty("identical").GetBoolean()); + Assert.Equal("data_only", dataOnly.GetProperty("summary").GetProperty("comparison_mode").GetString()); + var readiness = GetCategory(dataOnly, "readiness_provenance"); + Assert.True(readiness.GetProperty("different").GetBoolean()); + Assert.False(readiness.GetProperty("included").GetBoolean()); + } + + var incompatible = DiffCommandRunner.ParseArgs( + [leftDb, rightDb, "--data-only", "--include-telemetry"]); + Assert.Equal("--data-only cannot be combined with --include-telemetry", incompatible.ParseError); + } + finally + { + TestProjectHelper.DeleteDirectory(leftRoot); + TestProjectHelper.DeleteDirectory(rightRoot); + } + } + [Fact] public void Run_PagedModesOnlyAdvertiseValidAdvancingContinuations_Issue4859() { @@ -727,6 +843,11 @@ UPDATE symbol_references var root = document.RootElement; Assert.Equal("different", root.GetProperty("status").GetString()); Assert.False(root.GetProperty("identical").GetBoolean()); + Assert.Contains( + "data:reference_rows_changed", + root.GetProperty("summary").GetProperty("difference_reasons") + .EnumerateArray() + .Select(item => item.GetString())); var referenceRecords = GetRecords(root, "reference"); Assert.Contains(referenceRecords, record => record.GetProperty("side").GetString() == "left"); Assert.Contains(referenceRecords, record => record.GetProperty("side").GetString() == "right"); @@ -750,14 +871,19 @@ public void Run_DetailedJsonReportsOperationalMetadataDrift_Issue4357() SetMeta(leftDb, DbContext.IndexedProjectRootMetaKey, Path.GetFullPath(leftRoot)); SetMeta(rightDb, DbContext.IndexedProjectRootMetaKey, Path.GetFullPath(rightRoot)); - var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--json", "--detailed", "--limit", "5"]); + var (exitCode, output) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--data-only", "--limit", "5"]); Assert.Equal(0, exitCode); using var document = JsonDocument.Parse(output); var root = document.RootElement; Assert.Equal("identical", root.GetProperty("status").GetString()); Assert.True(root.GetProperty("identical").GetBoolean()); - var drift = Assert.Single(GetRecords(root, "operational_metadata")); + Assert.Equal("data_only", root.GetProperty("summary").GetProperty("comparison_mode").GetString()); + var readinessCategory = GetCategory(root, "readiness_provenance"); + Assert.True(readinessCategory.GetProperty("different").GetBoolean()); + Assert.False(readinessCategory.GetProperty("included").GetBoolean()); + var drift = Assert.Single(GetRecords(root, "readiness_provenance_metadata")); Assert.Equal("changed", drift.GetProperty("side").GetString()); Assert.True(GetField(drift, "key").GetProperty("redacted").GetBoolean()); Assert.True(GetField(drift, "left_value").GetProperty("redacted").GetBoolean()); @@ -783,13 +909,14 @@ public void Run_DetailedJsonReportsOperationalMetadataDrift_Issue4357() SetMeta(rightDb, key, $"right-{key}"); } - const int completePageBudget = 7_500; + const int completePageBudget = 12_000; var (boundedExitCode, boundedOutput) = RunWithCapturedOut( [ leftDb, rightDb, "--json", "--detailed", + "--data-only", "--limit", "100", "--max-json-bytes", @@ -800,8 +927,8 @@ public void Run_DetailedJsonReportsOperationalMetadataDrift_Issue4357() Assert.InRange(Encoding.UTF8.GetByteCount(boundedOutput), 1, completePageBudget); using var boundedDocument = JsonDocument.Parse(boundedOutput); var boundedRoot = boundedDocument.RootElement; - Assert.Equal(10, boundedRoot.GetProperty("total_count").GetInt64()); - Assert.Equal(10, boundedRoot.GetProperty("returned_count").GetInt32()); + Assert.Equal(9, boundedRoot.GetProperty("total_count").GetInt64()); + Assert.Equal(9, boundedRoot.GetProperty("returned_count").GetInt32()); Assert.False(boundedRoot.GetProperty("truncated").GetBoolean()); } finally @@ -827,14 +954,14 @@ public void Run_DetailedJsonTreatsWriterVersionAsOperationalMetadataDrift_Issue4 SetMeta(rightDb, DbContext.CdidxWriterVersionMetaKey, "writer-right"); var (exitCode, output) = RunWithCapturedOut( - [leftDb, rightDb, "--json", "--detailed", "--include-content", "--limit", "5"]); + [leftDb, rightDb, "--json", "--detailed", "--data-only", "--include-content", "--limit", "5"]); Assert.Equal(0, exitCode); using var document = JsonDocument.Parse(output); var root = document.RootElement; Assert.Equal("identical", root.GetProperty("status").GetString()); Assert.True(root.GetProperty("identical").GetBoolean()); - var drift = Assert.Single(GetRecords(root, "operational_metadata")); + var drift = Assert.Single(GetRecords(root, "readiness_provenance_metadata")); Assert.Equal("cdidx_writer_version", GetField(drift, "key").GetProperty("value").GetString()); Assert.Equal("writer-left", GetField(drift, "left_value").GetProperty("value").GetString()); Assert.Equal("writer-right", GetField(drift, "right_value").GetProperty("value").GetString()); @@ -967,7 +1094,8 @@ public void Run_DetailedJsonHashesLargeSymbolFieldsByDefault_Issue3163() var longSignature = new string('a', LargeDiffFieldLength); InsertSyntheticMethodSymbol(leftDb, "src/Same.cs", "Drifted", longSignature); - var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--json", "--detailed", "--limit", "1"]); + var (exitCode, output) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--data-only", "--limit", "1"]); Assert.Equal(1, exitCode); using var document = JsonDocument.Parse(output); @@ -1036,7 +1164,7 @@ public void Run_SummaryOnlyDetectsLargeRowDriftAfterSharedDisplayPrefix_Issue316 UpdateFirstChunkContent(leftDb, sharedPrefix + "left"); UpdateFirstChunkContent(rightDb, sharedPrefix + "right"); - var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--summary-only"]); + var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--summary-only", "--data-only"]); Assert.Equal(1, exitCode); using var document = JsonDocument.Parse(output); @@ -1095,7 +1223,8 @@ public void Run_DetailedJsonHandlesLegacySymbolRowsWithoutMetadataTargetSource_I TestProjectHelper.InsertIndexedFile(rightDb, "src/Same.cs", "csharp", "public class Same { }"); RecreateSymbolsTableWithoutMetadataTargetSourceColumn(leftDb); - var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--json", "--detailed", "--limit", "1"]); + var (exitCode, output) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--data-only", "--limit", "1"]); Assert.Equal(0, exitCode); using var document = JsonDocument.Parse(output); @@ -1119,7 +1248,8 @@ public void Run_ReturnsSuccessForSeparatelyBuiltIdenticalDatabases_Issue1724() var leftDb = SeedDb(leftRoot, includeExtraFile: false); var rightDb = SeedDb(rightRoot, includeExtraFile: false); - var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--summary-only"]); + var (exitCode, output) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only", "--data-only"]); Assert.Equal(0, exitCode); using var document = JsonDocument.Parse(output); @@ -1148,7 +1278,12 @@ public void Run_ReturnsSchemaMismatchExitCodeBeforeDriftExitCode_Issue1724() Assert.Equal(2, exitCode); using var document = JsonDocument.Parse(output); - Assert.False(document.RootElement.GetProperty("summary").GetProperty("schema_versions_equal").GetBoolean()); + var summary = document.RootElement.GetProperty("summary"); + Assert.False(summary.GetProperty("schema_versions_equal").GetBoolean()); + Assert.Contains( + "schema:schema_version_changed", + summary.GetProperty("difference_reasons").EnumerateArray().Select(item => item.GetString())); + Assert.False(GetCategory(document.RootElement, "data").GetProperty("evaluated").GetBoolean()); } finally { @@ -1671,6 +1806,13 @@ private static List GetRecords(JsonElement root, string area, strin && (side is null || record.GetProperty("side").GetString() == side)) .ToList(); + private static JsonElement GetCategory(JsonElement root, string category) + => Assert.Single( + root.GetProperty("summary") + .GetProperty("categories") + .EnumerateArray() + .Where(item => item.GetProperty("category").GetString() == category)); + private static JsonElement GetField(JsonElement record, string name) => Assert.Single( record.GetProperty("fields") From 714ca7b4069dc93fabc28fe6dbd3435fa15065ab Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 02:03:00 +0900 Subject: [PATCH 2/3] Address diff review findings (#4884) --- src/CodeIndex/Cli/DiffCommandRunner.cs | 154 +++++++----------- .../CodeIndex.Tests/DiffCommandRunnerTests.cs | 52 +++++- 2 files changed, 105 insertions(+), 101 deletions(-) diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index 058052dc7..806b48b4f 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -363,47 +363,16 @@ ORDER BY file_issues.message """; - private const string ContractMetaRowsSql = """ + private const string ReadinessProvenanceMetaRowsSql = """ SELECT key, value FROM codeindex_meta WHERE - key = 'hotspot_family_version' - OR key = 'hotspot_family_marker_fingerprint' - OR key LIKE 'hotspot_family_version_%' - OR key LIKE 'hotspot_family_marker_fingerprint_%' - OR key = 'csharp_symbol_name_contract_version' - OR key = 'sql_graph_contract_version' - OR key LIKE 'symbol_extractor_version_%' - OR key LIKE 'metadata_target_version_%' - ORDER BY - key, - value - """; - - private const string ProvenanceMetaRowsSql = """ - SELECT - key, - value - FROM codeindex_meta - WHERE - key = 'indexed_project_root' - OR key = 'indexed_follow_symlinks_policy' - OR key = 'indexed_head_commit' - OR key = 'indexed_head_commit_branch' - OR key = 'indexed_head_sha' - OR key = 'indexed_head_branch' - OR key = 'commit_scoped_fresh_head_sha' - OR key = 'workspace_path_case_sensitive' - OR key = 'unknown_extension_file_count' - OR key = 'unknown_extension_file_paths_json' - OR key = 'unknown_extension_files_truncated' - OR key = 'unknown_extension_file_path_limit' - OR key = 'unknown_extension_extension_counts_json' - OR key = 'unknown_extension_category_counts_json' - OR key = 'unknown_extension_groups_json' - OR key = 'cdidx_writer_version' + key <> 'indexed_head_timestamp' + AND key <> 'last_full_scan_elapsed_ms' + AND key NOT LIKE 'last_index_run_%' + AND key NOT LIKE 'last_failed_index_run_%' ORDER BY key, value @@ -416,7 +385,9 @@ ORDER BY FROM codeindex_meta WHERE key = 'indexed_head_timestamp' + OR key = 'last_full_scan_elapsed_ms' OR key LIKE 'last_index_run_%' + OR key LIKE 'last_failed_index_run_%' ORDER BY key, value @@ -514,8 +485,7 @@ private static DiffJsonResult BuildDiff( bool chunkRowsEqual; bool referenceLineRowsEqual; bool fileIssueRowsEqual; - bool contractMetadataEqual; - bool provenanceMetadataEqual; + bool readinessProvenanceMetadataEqual; bool volatileTelemetryEqual; if (collector is not null) { @@ -586,19 +556,10 @@ private static DiffJsonResult BuildDiff( cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - contractMetadataEqual = CollectMetadataRows( - leftConnection, - rightConnection, - ContractMetaRowsSql, - "contract_metadata", - collector, - cancellationToken); - - cancellationToken.ThrowIfCancellationRequested(); - provenanceMetadataEqual = CollectMetadataRows( + readinessProvenanceMetadataEqual = CollectMetadataRows( leftConnection, rightConnection, - ProvenanceMetaRowsSql, + ReadinessProvenanceMetaRowsSql, "readiness_provenance_metadata", collector, cancellationToken); @@ -620,47 +581,58 @@ private static DiffJsonResult BuildDiff( } else { + var countBasedDataDifference = + left.FileCount != right.FileCount + || left.SymbolCount != right.SymbolCount + || left.ReferenceCount != right.ReferenceCount; + if (countBasedDataDifference) + { + fileRowsEqual = left.FileCount == right.FileCount; + symbolRowsEqual = left.SymbolCount == right.SymbolCount; + referenceRowsEqual = left.ReferenceCount == right.ReferenceCount; + chunkRowsEqual = true; + referenceLineRowsEqual = true; + fileIssueRowsEqual = true; + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + fileRowsEqual = RowsEqual(leftConnection, rightConnection, FileRowsSql, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + symbolRowsEqual = RowsEqual( + leftConnection, + rightConnection, + leftSymbolRowsSql, + rightSymbolRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + referenceRowsEqual = RowsEqual( + leftConnection, + rightConnection, + leftReferenceRowsSql, + rightReferenceRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + chunkRowsEqual = RowsEqual(leftConnection, rightConnection, ChunkRowsSql, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + referenceLineRowsEqual = RowsEqual( + leftConnection, + rightConnection, + ReferenceLineRowsSql, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + fileIssueRowsEqual = RowsEqual( + leftConnection, + rightConnection, + FileIssueRowsSql, + cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); - fileRowsEqual = RowsEqual(leftConnection, rightConnection, FileRowsSql, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - symbolRowsEqual = RowsEqual( - leftConnection, - rightConnection, - leftSymbolRowsSql, - rightSymbolRowsSql, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - referenceRowsEqual = RowsEqual( - leftConnection, - rightConnection, - leftReferenceRowsSql, - rightReferenceRowsSql, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - chunkRowsEqual = RowsEqual(leftConnection, rightConnection, ChunkRowsSql, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - referenceLineRowsEqual = RowsEqual( - leftConnection, - rightConnection, - ReferenceLineRowsSql, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - fileIssueRowsEqual = RowsEqual( - leftConnection, - rightConnection, - FileIssueRowsSql, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - contractMetadataEqual = RowsEqual( - leftConnection, - rightConnection, - ContractMetaRowsSql, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - provenanceMetadataEqual = RowsEqual( + readinessProvenanceMetadataEqual = RowsEqual( leftConnection, rightConnection, - ProvenanceMetaRowsSql, + ReadinessProvenanceMetaRowsSql, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); volatileTelemetryEqual = RowsEqual( @@ -678,12 +650,8 @@ private static DiffJsonResult BuildDiff( AddDifferenceReason(dataReasons, fileIssueRowsEqual, "file_issue_rows_changed"); AddDifferenceReason( readinessProvenanceReasons, - contractMetadataEqual, - "contract_metadata_changed"); - AddDifferenceReason( - readinessProvenanceReasons, - provenanceMetadataEqual, - "provenance_metadata_changed"); + readinessProvenanceMetadataEqual, + "readiness_provenance_metadata_changed"); AddDifferenceReason( telemetryReasons, volatileTelemetryEqual, diff --git a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs index 78bbc4315..44d0ea4b2 100644 --- a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs @@ -209,6 +209,7 @@ public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() SetMeta(rightDb, "last_index_run_duration_ms", "123"); SetMeta(rightDb, "last_index_run_mode", "incremental"); SetMeta(rightDb, "last_index_run_bytes_read", "0"); + SetMeta(rightDb, DbContext.LastFullScanElapsedMsMetaKey, "456"); var (semanticExitCode, semanticOutput) = RunWithCapturedOut( [leftDb, rightDb, "--summary-only"]); @@ -257,12 +258,18 @@ public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() using (var telemetryDetailedDocument = JsonDocument.Parse(telemetryDetailedOutput)) { Assert.Equal( - 5, + 6, GetRecords(telemetryDetailedDocument.RootElement, "volatile_telemetry_metadata").Count); } SetMeta(leftDb, "hotspot_family_version", "left-readiness"); SetMeta(rightDb, "hotspot_family_version", "right-readiness"); + SetMeta(leftDb, DbContext.IndexCompletenessMetaKey, "complete"); + SetMeta(rightDb, DbContext.IndexCompletenessMetaKey, "incomplete"); + SetMeta(leftDb, DbContext.IndexIncompleteReasonsMetaKey, null); + SetMeta(rightDb, DbContext.IndexIncompleteReasonsMetaKey, """["symbols_only"]"""); + SetMeta(leftDb, DbContext.ReferenceIdentityContractVersionMetaKey, "5"); + SetMeta(rightDb, DbContext.ReferenceIdentityContractVersionMetaKey, "6"); var (readinessExitCode, readinessOutput) = RunWithCapturedOut( [leftDb, rightDb, "--summary-only"]); var (dataOnlyExitCode, dataOnlyOutput) = RunWithCapturedOut( @@ -272,7 +279,7 @@ public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() using (var readinessDocument = JsonDocument.Parse(readinessOutput)) { Assert.Contains( - "readiness_provenance:contract_metadata_changed", + "readiness_provenance:readiness_provenance_metadata_changed", readinessDocument.RootElement.GetProperty("summary").GetProperty("difference_reasons") .EnumerateArray() .Select(item => item.GetString())); @@ -1041,17 +1048,46 @@ public void Run_StopsWhenDiffRowBudgetIsExceeded_Issue3834() { var leftDb = TestProjectHelper.CreateProjectDb(leftRoot); var rightDb = TestProjectHelper.CreateProjectDb(rightRoot); - TestProjectHelper.InsertIndexedFile(leftDb, "src/A.cs", "csharp", "public class A { }"); - TestProjectHelper.InsertIndexedFile(leftDb, "src/B.cs", "csharp", "public class B { }"); - TestProjectHelper.InsertIndexedFile(rightDb, "src/A.cs", "csharp", "public class A { }"); - TestProjectHelper.InsertIndexedFile(rightDb, "src/B.cs", "csharp", "public class B { }"); - DiffCommandRunner.MaxDiffComparedRowsPerSideForTesting = 1; + for (var i = 0; i < 11; i++) + { + TestProjectHelper.InsertIndexedFile( + leftDb, + $"src/File{i:00}.cs", + "csharp", + $"public class File{i:00} {{ }}"); + TestProjectHelper.InsertIndexedFile( + rightDb, + $"src/File{i:00}.cs", + "csharp", + $"public class File{i:00} {{ }}"); + } + ExecuteNonQuery( + rightDb, + """ + INSERT INTO files (path, lang, size, lines, checksum, modified) + VALUES ('src/C.cs', 'csharp', 1, 1, 'c', '2026-01-01T00:00:00Z'); + """); + DiffCommandRunner.MaxDiffComparedRowsPerSideForTesting = 10; + + var (differentExitCode, differentOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only"]); + + Assert.Equal(1, differentExitCode); + using (var differentDocument = JsonDocument.Parse(differentOutput)) + { + Assert.Contains( + "data:file_rows_changed", + differentDocument.RootElement.GetProperty("summary").GetProperty("difference_reasons") + .EnumerateArray() + .Select(item => item.GetString())); + } + ExecuteNonQuery(rightDb, "DELETE FROM files WHERE path = 'src/C.cs';"); var (exitCode, stdout, stderr) = RunWithCapturedStreams([leftDb, rightDb]); Assert.Equal(3, exitCode); Assert.Equal(string.Empty, stdout); - Assert.Contains("diff left row comparison exceeded the safety budget of 1 rows", stderr); + Assert.Contains("diff left row comparison exceeded the safety budget of 10 rows", stderr); } finally { From 3e1a997feace3adfb6f3dcb033b08a00f28fe262 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 02:17:27 +0900 Subject: [PATCH 3/3] Resolve final diff review findings (#4884) --- src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/DiffCommandRunner.cs | 40 +++++++++++++----- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 11 +++++ .../CodeIndex.Tests/DiffCommandRunnerTests.cs | 42 +++++++++++++++---- 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index fe4322dcf..356d94e6d 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -491,6 +491,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", PrimaryCommands = Set(EntrypointConfidenceCommands) }, new() { Name = "--sections", ValuePlaceholder = "", Description = "Map: comma-separated response sections to include, or list to discover sections", PrimaryCommands = Set(MapSectionCommands) }, new() { Name = "--summary-only", Description = "Map/Diff/Recipes/Audit/Files/Symbols/Deps/Hotspots/Languages: return only aggregate summary fields where supported", PrimaryCommands = Set(SummaryOnlyCommands) }, + new() { Name = "--data-only", Description = "Diff: include indexed data and schema in identity while excluding readiness/provenance and volatile telemetry", PrimaryCommands = Set("diff") }, + new() { Name = "--include-telemetry", Description = "Diff: include volatile index-run and FTS maintenance telemetry in identity", PrimaryCommands = Set("diff") }, new() { Name = "--cycles", Description = "Deps: return deterministically ranked dependency SCCs with stable pagination", PrimaryCommands = Set(DependencyCycleCommands) }, new() { Name = "--graph-budget", ValuePlaceholder = "", Description = $"Deps cycles: maximum graph edges analyzed for SCC completeness (default: {QueryCommandRunner.DefaultDependencyCycleGraphBudget})", PrimaryCommands = Set(DependencyCycleCommands) }, new() { Name = "--suppress-noise", Description = "Deps: suppress generic framework symbols and legacy Markdown heading-name fanout while preserving explicit links", PrimaryCommands = Set("deps") }, diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index 806b48b4f..0341bd4d8 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -371,6 +371,10 @@ FROM codeindex_meta WHERE key <> 'indexed_head_timestamp' AND key <> 'last_full_scan_elapsed_ms' + AND key <> 'fts_last_optimized_at' + AND key <> 'fts_last_optimize_duration_ms' + AND key <> 'fts_incremental_writes_since_optimize' + AND key <> 'fts_incremental_writes_since_merge' AND key NOT LIKE 'last_index_run_%' AND key NOT LIKE 'last_failed_index_run_%' ORDER BY @@ -386,6 +390,10 @@ FROM codeindex_meta WHERE key = 'indexed_head_timestamp' OR key = 'last_full_scan_elapsed_ms' + OR key = 'fts_last_optimized_at' + OR key = 'fts_last_optimize_duration_ms' + OR key = 'fts_incremental_writes_since_optimize' + OR key = 'fts_incremental_writes_since_merge' OR key LIKE 'last_index_run_%' OR key LIKE 'last_failed_index_run_%' ORDER BY @@ -487,6 +495,10 @@ private static DiffJsonResult BuildDiff( bool fileIssueRowsEqual; bool readinessProvenanceMetadataEqual; bool volatileTelemetryEqual; + var countBasedDataDifference = + left.FileCount != right.FileCount + || left.SymbolCount != right.SymbolCount + || left.ReferenceCount != right.ReferenceCount; if (collector is not null) { cancellationToken.ThrowIfCancellationRequested(); @@ -581,10 +593,6 @@ private static DiffJsonResult BuildDiff( } else { - var countBasedDataDifference = - left.FileCount != right.FileCount - || left.SymbolCount != right.SymbolCount - || left.ReferenceCount != right.ReferenceCount; if (countBasedDataDifference) { fileRowsEqual = left.FileCount == right.FileCount; @@ -642,12 +650,24 @@ private static DiffJsonResult BuildDiff( cancellationToken); } - AddDifferenceReason(dataReasons, fileRowsEqual, "file_rows_changed"); - AddDifferenceReason(dataReasons, symbolRowsEqual, "symbol_rows_changed"); - AddDifferenceReason(dataReasons, referenceRowsEqual, "reference_rows_changed"); - AddDifferenceReason(dataReasons, chunkRowsEqual, "chunk_rows_changed"); - AddDifferenceReason(dataReasons, referenceLineRowsEqual, "reference_line_rows_changed"); - AddDifferenceReason(dataReasons, fileIssueRowsEqual, "file_issue_rows_changed"); + if (countBasedDataDifference) + { + // Header count differences already prove the data category differs. Use the + // same bounded reason set in summary and detailed modes; detailed mode may + // still collect additional records, but it must not change summary identity. + AddDifferenceReason(dataReasons, left.FileCount == right.FileCount, "file_rows_changed"); + AddDifferenceReason(dataReasons, left.SymbolCount == right.SymbolCount, "symbol_rows_changed"); + AddDifferenceReason(dataReasons, left.ReferenceCount == right.ReferenceCount, "reference_rows_changed"); + } + else + { + AddDifferenceReason(dataReasons, fileRowsEqual, "file_rows_changed"); + AddDifferenceReason(dataReasons, symbolRowsEqual, "symbol_rows_changed"); + AddDifferenceReason(dataReasons, referenceRowsEqual, "reference_rows_changed"); + AddDifferenceReason(dataReasons, chunkRowsEqual, "chunk_rows_changed"); + AddDifferenceReason(dataReasons, referenceLineRowsEqual, "reference_line_rows_changed"); + AddDifferenceReason(dataReasons, fileIssueRowsEqual, "file_issue_rows_changed"); + } AddDifferenceReason( readinessProvenanceReasons, readinessProvenanceMetadataEqual, diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index a6b2a1926..5fa227aac 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -149,6 +149,17 @@ public void QualifiedCommonCallCompletenessFlag_IsScopedToGraphCommands_Issue486 Assert.DoesNotContain(flag, CliFlagSchema.GetAcceptedFlagNamesForCommand("search")); } + [Fact] + public void DiffComparisonModes_AreRegisteredForCompletions_Issue4884() + { + var flags = CliFlagSchema.GetCompletionFlagsForCommand("diff") + .Select(flag => flag.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.Contains("--data-only", flags); + Assert.Contains("--include-telemetry", flags); + } + [Fact] public void AuditAggregationFlags_SurfaceDocumentedRecipeGrouping_Issues4301_4339() { diff --git a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs index 44d0ea4b2..a36cbfcf6 100644 --- a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs @@ -210,6 +210,10 @@ public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() SetMeta(rightDb, "last_index_run_mode", "incremental"); SetMeta(rightDb, "last_index_run_bytes_read", "0"); SetMeta(rightDb, DbContext.LastFullScanElapsedMsMetaKey, "456"); + SetMeta(rightDb, DbWriter.FtsLastOptimizedAtMetaKey, "2026-07-29T01:02:04Z"); + SetMeta(rightDb, DbWriter.FtsLastOptimizeDurationMsMetaKey, "789"); + SetMeta(rightDb, DbWriter.FtsIncrementalWritesSinceOptimizeMetaKey, "3"); + SetMeta(rightDb, DbWriter.FtsIncrementalWritesSinceMergeMetaKey, "4"); var (semanticExitCode, semanticOutput) = RunWithCapturedOut( [leftDb, rightDb, "--summary-only"]); @@ -258,7 +262,7 @@ public void Run_CategorizesNoOpTelemetryAndReadinessAcrossModes_Issue4884() using (var telemetryDetailedDocument = JsonDocument.Parse(telemetryDetailedOutput)) { Assert.Equal( - 6, + 10, GetRecords(telemetryDetailedDocument.RootElement, "volatile_telemetry_metadata").Count); } @@ -1067,21 +1071,45 @@ public void Run_StopsWhenDiffRowBudgetIsExceeded_Issue3834() INSERT INTO files (path, lang, size, lines, checksum, modified) VALUES ('src/C.cs', 'csharp', 1, 1, 'c', '2026-01-01T00:00:00Z'); """); - DiffCommandRunner.MaxDiffComparedRowsPerSideForTesting = 10; + ExecuteNonQuery( + rightDb, + """ + UPDATE symbols + SET name = 'Drifted', name_folded = 'drifted' + WHERE id = (SELECT MIN(id) FROM symbols); + """); var (differentExitCode, differentOutput) = RunWithCapturedOut( [leftDb, rightDb, "--summary-only"]); + var (detailedExitCode, detailedOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--json", "--detailed", "--limit", "100"]); Assert.Equal(1, differentExitCode); + Assert.Equal(1, detailedExitCode); using (var differentDocument = JsonDocument.Parse(differentOutput)) + using (var detailedDocument = JsonDocument.Parse(detailedOutput)) { - Assert.Contains( - "data:file_rows_changed", - differentDocument.RootElement.GetProperty("summary").GetProperty("difference_reasons") - .EnumerateArray() - .Select(item => item.GetString())); + var summaryReasons = GetCategory(differentDocument.RootElement, "data") + .GetProperty("reasons") + .EnumerateArray() + .Select(item => item.GetString()) + .ToArray(); + var detailedReasons = GetCategory(detailedDocument.RootElement, "data") + .GetProperty("reasons") + .EnumerateArray() + .Select(item => item.GetString()) + .ToArray(); + Assert.Equal(["file_rows_changed"], summaryReasons); + Assert.Equal(summaryReasons, detailedReasons); + Assert.NotEmpty(GetRecords(detailedDocument.RootElement, "symbol")); } + DiffCommandRunner.MaxDiffComparedRowsPerSideForTesting = 10; + var (boundedExitCode, boundedOutput) = RunWithCapturedOut( + [leftDb, rightDb, "--summary-only"]); + Assert.Equal(1, boundedExitCode); + Assert.Contains("data:file_rows_changed", boundedOutput, StringComparison.Ordinal); + ExecuteNonQuery(rightDb, "DELETE FROM files WHERE path = 'src/C.cs';"); var (exitCode, stdout, stderr) = RunWithCapturedStreams([leftDb, rightDb]);