From f0099be6f15641276bc69dd9fa69ffdb8eba4b4f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 05:44:55 +0900 Subject: [PATCH 1/6] Fix no-op status freshness consistency (#5227) --- DEVELOPER_GUIDE.md | 18 +++ README.md | 15 +++ TESTING_GUIDE.md | 2 + changelog.d/unreleased/5227.fixed.md | 23 ++++ .../Cli/QueryCommandRunner.Status.cs | 21 +--- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 9 +- .../Mcp/McpToolHandlers.Query.Status.cs | 2 +- .../Models/StatusFreshnessEvaluator.cs | 109 ++++++++++++++++++ .../IndexCommandRunnerTests.cs | 95 +++++++++++++++ .../StatusFreshnessEvaluatorTests.cs | 106 +++++++++++++++++ 10 files changed, 378 insertions(+), 22 deletions(-) create mode 100644 changelog.d/unreleased/5227.fixed.md create mode 100644 src/CodeIndex/Models/StatusFreshnessEvaluator.cs create mode 100644 tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index db4603545..dc4d9ba62 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1519,6 +1519,15 @@ Current stable codes and triggers: | Memory tracing | `index --json --memory-trace` adds a `memory_timeline` block to the CLI index result and persists peak working-set MB into `last_index_run`; dry-run results also emit live `start`, `snapshot`, `scan`, and `finalize` samples but never persist run metadata. `index --dry-run --rebuild` bypasses destructive confirmation because it does not delete or rewrite the index. `CDIDX_MEM_WARN_MB=` prints a warning when the sampled working set crosses that threshold. | | Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. | +Status freshness summaries are classified by one shared evaluator. An +authoritative `status --check` result takes precedence. Without that check, +`last_workspace_freshened_at >= latest_modified` can prove a checksum-reused +no-op update fresh only when the worktree is clean and the runtime, +workspace-verified, and latest-index HEAD SHAs all agree. Missing provenance and +future timestamps are `unknown`; a later modification, dirty worktree, or +changed HEAD remains conservative. Ordinary `head_freshness=head_current` +semantics remain distinct from the authoritative checked `fresh` value. + `vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics. ### Data directory resolution @@ -5687,6 +5696,15 @@ apply 時は `PRAGMA optimize` を実行します。 | memory tracing | `index --json --memory-trace` は CLI index 結果に `memory_timeline` block を追加し、peak working-set MB を `last_index_run` に保存します。dry-run 結果も live な `start`、`snapshot`、`scan`、`finalize` sample を返しますが、run metadata は保存しません。`index --dry-run --rebuild` は index を削除も rewrite もしないため destructive confirmation を bypass します。`CDIDX_MEM_WARN_MB=` は sampled working set がしきい値を超えたときに warning を出します。 | | newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 | +status freshness summary は1つの共有 evaluator で分類します。authoritative な +`status --check` の結果を優先します。check がない場合、 +`last_workspace_freshened_at >= latest_modified` が checksum 再利用 no-op update の +freshness を証明できるのは、worktree が clean で、runtime、workspace 検証済み、直近 +index の HEAD SHA がすべて一致するときだけです。provenance 不足と未来 timestamp は +`unknown` とし、後続の変更、dirty worktree、HEAD 変更は保守的な判定を維持します。 +通常 status の `head_freshness=head_current` は、authoritative check 済みの `fresh` と +引き続き区別します。 + `vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 ### データディレクトリ解決 diff --git a/README.md b/README.md index f039ae2f6..bd5be453f 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,14 @@ visible here as a compact compatibility index. | Remediation | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | | MCP-only session diagnostics | `mcp_session`, `mcp_session.metrics`, `queue_capacity`, `queue_depth`, `queued_event_count`, `written_event_count`, `dropped_event_count`, `queue_full_drop_count`, `serialization_failure_count`, `write_failure_count`, `rotation_failure_count`, `batch_flush_count`, `consecutive_failure_count`, `recovery_count`, `next_retry_at`, `last_recovery_at`, `last_failure`, `mcp_session.audit_log`, `queued_record_count`, `written_record_count`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`. | +Ordinary status summaries use `last_workspace_freshened_at` as freshness evidence +after a checksum-reused no-op update only when the runtime HEAD, +`workspace_verified_head_sha`, and `indexed_head_sha` agree and the worktree is +clean. Missing provenance or a future timestamp yields `unknown`; an actual +workspace change remains `stale`. `status --check` performs the authoritative +workspace comparison, and CLI, workspace, and MCP summaries share the same +classification. + Persisted JSON subdocuments for `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, and `last_failed_or_partial_index_run.file_errors` have a 512 KiB UTF-8 input limit @@ -443,6 +451,13 @@ field group を表に残します。 | remediation | `degraded_root_cause`、`degraded_reason`、`recommended_action`、`alternative_action`、`readiness_degradations`、`repair_commands`。 | | MCP-only session diagnostics | `mcp_session`、`mcp_session.metrics`、`queue_capacity`、`queue_depth`、`queued_event_count`、`written_event_count`、`dropped_event_count`、`queue_full_drop_count`、`serialization_failure_count`、`write_failure_count`、`rotation_failure_count`、`batch_flush_count`、`consecutive_failure_count`、`recovery_count`、`next_retry_at`、`last_recovery_at`、`last_failure`、`mcp_session.audit_log`、`queued_record_count`、`written_record_count`、`mcp.rate_limit.bucket_limit`、`mcp.rate_limit.bucket_limit_rejection_count`。 | +通常の status summary は、checksum 再利用による no-op update 後の鮮度証拠として +`last_workspace_freshened_at` を使います。ただし runtime HEAD、 +`workspace_verified_head_sha`、`indexed_head_sha` が一致し、worktree が clean な場合に +限ります。provenance が欠けている場合や timestamp が未来の場合は `unknown`、実際の +workspace 変更がある場合は引き続き `stale` です。`status --check` は authoritative な +workspace 比較を行い、CLI、workspace、MCP の summary は同じ分類を共有します。 + `last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、 `last_failed_or_partial_index_run.file_errors` の永続化 JSON subdocument には、 UTF-8 で 512 KiB の入力上限と最大 depth 16 を適用します。file error と cap-hit diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ffd1e31f5..0eb06be8f 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 #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs` and `IndexCommandRunnerTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, and changed HEADs. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, and MCP summary parity across both target frameworks. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - Issue #5198 CODEOWNERS coverage belongs in `FileIndexerTests.cs`, `SymbolExtractorRepositoryMetadataTests.cs`, `QueryCommandRunnerFilesTests.cs`, `IndexCommandRunnerUpdateTests.cs`, `QueryCommandRunnerTests.cs`, and `McpServerToolsCallTests.cs`. Preserve the three case-sensitive Git-worktree-relative locations and the arbitrary-nested/case-variant/POSIX-literal-backslash negative cases, including scans rooted below the enclosing worktree; parser cases for full-line and inline comments, blank lines, whitespace, CRLF, rejection of unsupported escaped leading `#`, ownerless rules, user/team/email owners, malformed mentions, duplicate and overlapping patterns, invalid input, and persistable bounded diagnostics; ordered rule and owner child symbols; symbol-only capability guidance; full/scoped/delete indexing; branch-switch reconciliation; and removal from unknown-extension status diagnostics. @@ -1172,6 +1173,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs` と `IndexCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD 変更について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、MCP の summary が両 target framework で一致することを assertion します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - Issue #5198 の CODEOWNERS coverage は `FileIndexerTests.cs`、`SymbolExtractorRepositoryMetadataTests.cs`、`QueryCommandRunnerFilesTests.cs`、`IndexCommandRunnerUpdateTests.cs`、`QueryCommandRunnerTests.cs`、`McpServerToolsCallTests.cs` が担当します。case-sensitive な3つの Git worktree-relative location と、enclosing worktree より下を scan root にした場合を含む任意の nested file・大小文字違い file・POSIX の literal backslash file の negative case、full-line / inline comment・blank line・whitespace・CRLF・未対応である先頭 `#` の escape の拒否・ownerless rule・user/team/email owner・malformed mention・duplicate / overlapping pattern・不正 input・永続化可能な上限付き diagnostic の parser case、順序付き rule と owner child symbol、symbol-only capability guidance、full / scoped / delete indexing、branch-switch reconciliation、unknown-extension status diagnostic からの除外を維持してください。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md new file mode 100644 index 000000000..d3cf938a6 --- /dev/null +++ b/changelog.d/unreleased/5227.fixed.md @@ -0,0 +1,23 @@ +--- +category: fixed +issues: + - 5227 +affected: + - src/CodeIndex/Models/StatusFreshnessEvaluator.cs + - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs + - tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, closing the residual status contradiction from #3238 while preserving stale, missing-provenance, changed-HEAD, and clock-skew boundaries. + +## 日本語 + +- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立し、#3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、HEAD 変更、clock skew の境界を維持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index ebf39cd56..dabd3d470 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -1371,21 +1371,8 @@ private static string BuildFoldNotReadyExplanation(string? foldReadyReason) private static string BuildFoldNotReadyWarning(string? foldReadyReason, string backfillCommand, string rebuildCommand) => $"{BuildFoldNotReadyExplanation(foldReadyReason)} Run `{backfillCommand}` to restamp folded-name columns in place, or `{rebuildCommand}` for a full rebuild."; - private static string BuildStatusFreshnessLabel(StatusResult status) - { - if (status.WorkspaceCheck != null) - return status.WorkspaceCheck.Checked - ? (status.WorkspaceCheck.MatchesWorkspace ? "fresh" : "stale") - : "unknown"; - - if (!status.IndexedAt.HasValue || !status.LatestModified.HasValue) - return "unknown"; - - if (status.GitIsDirty == true) - return "stale"; - - return status.IndexedAt.Value >= status.LatestModified.Value ? "fresh" : "stale"; - } + private static string BuildStatusFreshnessLabel(StatusResult status, DateTime utcNow) + => StatusFreshnessEvaluator.Evaluate(status, utcNow).SummaryLabel; private static void WriteWorkspaceCheck(IndexFreshnessCheckResult check) { @@ -1480,10 +1467,10 @@ internal static void ApplyStatusSymbolKindLimits(StatusResult status, Dictionary status.SymbolsByLanguageKindNamesTruncated = truncatedLanguages; } - internal static string BuildStatusSummary(StatusResult status) + internal static string BuildStatusSummary(StatusResult status, DateTime? utcNow = null) { var topLangs = status.Languages.OrderByDescending(kv => kv.Value).Take(3).Select(kv => kv.Key); - var freshness = BuildStatusFreshnessLabel(status); + var freshness = BuildStatusFreshnessLabel(status, utcNow ?? GetUtcNow()); var dirty = status.GitIsDirty == true ? ", dirty" : ""; var degraded = IsStatusDegraded(status) ? ", DEGRADED" : ""; var incomplete = status.IndexComplete ? "" : ", INCOMPLETE"; diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 46ec9906f..5d16f8d52 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -430,17 +430,18 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( projectRoot, cancellationToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + var freshnessEvaluation = StatusFreshnessEvaluator.Evaluate(freshness); var status = "ready"; var reason = "ready"; - if (!freshness.Checked) + if (freshnessEvaluation.State == StatusFreshnessState.Unknown) { status = "degraded"; - reason = "freshness_check_unavailable"; + reason = freshnessEvaluation.Reason; } - else if (!freshness.MatchesWorkspace) + else if (freshnessEvaluation.State != StatusFreshnessState.Fresh) { status = "stale"; - reason = freshness.Reason; + reason = freshnessEvaluation.Reason; } else if (!snapshot.IndexComplete) { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 47d842ee2..e6592ab4c 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -115,7 +115,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit); status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); } - status.Summary = QueryCommandRunner.BuildStatusSummary(status); + status.Summary = QueryCommandRunner.BuildStatusSummary(status, GetUtcNow()); var checkFailures = checkWorkspace ? BuildMcpStatusCheckFailures(status, statusScopes) : []; diff --git a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs new file mode 100644 index 000000000..ac3f67021 --- /dev/null +++ b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs @@ -0,0 +1,109 @@ +namespace CodeIndex.Database; + +internal enum StatusFreshnessState +{ + Fresh, + Stale, + Unknown, + HeadChanged, + ClockSkew, +} + +internal readonly record struct StatusFreshnessEvaluation( + StatusFreshnessState State, + string Reason) +{ + public string SummaryLabel => State switch + { + StatusFreshnessState.Fresh => "fresh", + StatusFreshnessState.Stale or StatusFreshnessState.HeadChanged => "stale", + _ => "unknown", + }; +} + +/// +/// Classifies status freshness from either an authoritative workspace check or +/// the bounded metadata available to ordinary status. +/// authoritative な workspace check、または通常 status で利用できる bounded metadata から +/// freshness を分類する。 +/// +internal static class StatusFreshnessEvaluator +{ + public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime utcNow) + { + if (status.WorkspaceCheck is not null) + return Evaluate(status.WorkspaceCheck); + + if (status.WorktreeHeadChanged == true) + return new(StatusFreshnessState.HeadChanged, "head_changed"); + + if (status.GitIsDirty == true) + return new(StatusFreshnessState.Stale, "worktree_dirty"); + + if (!status.IndexedAt.HasValue || !status.LatestModified.HasValue) + return new(StatusFreshnessState.Unknown, "timestamps_unavailable"); + + var indexedAt = NormalizeUtc(status.IndexedAt.Value); + var latestModified = NormalizeUtc(status.LatestModified.Value); + var lastWorkspaceFreshenedAt = status.LastWorkspaceFreshenedAt.HasValue + ? NormalizeUtc(status.LastWorkspaceFreshenedAt.Value) + : (DateTime?)null; + var normalizedNow = NormalizeUtc(utcNow); + + if (indexedAt > normalizedNow + || latestModified > normalizedNow + || (lastWorkspaceFreshenedAt.HasValue && lastWorkspaceFreshenedAt.Value > normalizedNow)) + { + return new(StatusFreshnessState.ClockSkew, "timestamp_in_future"); + } + + if (indexedAt >= latestModified) + return new(StatusFreshnessState.Fresh, "indexed_after_latest_modified"); + + if (!lastWorkspaceFreshenedAt.HasValue + || lastWorkspaceFreshenedAt.Value < latestModified) + { + return new(StatusFreshnessState.Stale, "latest_modified_after_freshness_evidence"); + } + + return HasTrustedFresheningContext(status) + ? new(StatusFreshnessState.Fresh, "workspace_freshened_after_latest_modified") + : new(StatusFreshnessState.Unknown, "workspace_freshening_unverified"); + } + + public static StatusFreshnessEvaluation Evaluate(IndexFreshnessCheckResult check) + { + if (!check.Checked) + return new(StatusFreshnessState.Unknown, "freshness_check_unavailable"); + if (check.MatchesWorkspace) + return new(StatusFreshnessState.Fresh, check.Reason); + if (check.HeadChanged || string.Equals(check.Reason, "head_changed", StringComparison.Ordinal)) + return new(StatusFreshnessState.HeadChanged, check.Reason); + return new(StatusFreshnessState.Stale, check.Reason); + } + + private static bool HasTrustedFresheningContext(StatusResult status) + { + if (status.WorktreeHeadChanged != false || status.GitIsDirty != false) + return false; + + var runtimeHead = NullIfWhiteSpace(status.GitHead); + var workspaceVerifiedHead = NullIfWhiteSpace(status.WorkspaceVerifiedHeadSha); + var latestIndexHead = NullIfWhiteSpace(status.IndexedHeadSha); + return runtimeHead is not null + && workspaceVerifiedHead is not null + && latestIndexHead is not null + && string.Equals(runtimeHead, workspaceVerifiedHead, StringComparison.OrdinalIgnoreCase) + && string.Equals(workspaceVerifiedHead, latestIndexHead, StringComparison.OrdinalIgnoreCase); + } + + private static DateTime NormalizeUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 0c8e5e3c3..9da46a8ad 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Runtime.Versioning; using System.Runtime.InteropServices; @@ -11,6 +12,7 @@ using CodeIndex.Indexer; using CodeIndex.Indexer.Extensibility; using CodeIndex.Indexer.Hooks; +using CodeIndex.Mcp; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -6400,6 +6402,99 @@ public void Run_GitRepo_PersistsIndexedHeadMetadata() } } + [Fact] + public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgree_Issue5227() + { + var projectRoot = CreateTempProject(); + var dbPath = CreateTempDbPath("cdidx_status_noop_freshening_5227"); + var sourcePath = Path.Combine(projectRoot, "app.py"); + var initialNow = new DateTimeOffset(2099, 1, 2, 3, 0, 0, TimeSpan.Zero); + var clock = new ManualTimeProvider(initialNow); + IndexCommandRunner.TimeProvider = clock; + QueryCommandRunner.TimeProvider = clock; + try + { + File.WriteAllText(sourcePath, "print('hello')\n"); + File.SetLastWriteTimeUtc(sourcePath, initialNow.AddMinutes(-1).UtcDateTime); + RunGit(projectRoot, "init"); + RunGit(projectRoot, "checkout", "-B", "main"); + RunGit(projectRoot, "add", "app.py"); + RunGit(projectRoot, "commit", "-m", "initial"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + DateTime initialIndexedAt; + using (var initialDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var initialReader = new DbReader(initialDb)) + { + initialIndexedAt = Assert.IsType(initialReader.GetStatus().IndexedAt); + } + + File.SetLastWriteTimeUtc(sourcePath, initialNow.AddMinutes(1).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + + var (refreshExitCode, refreshJson) = RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, refreshExitCode); + Assert.Equal(1, refreshJson.GetProperty("summary").GetProperty("files_skipped").GetInt32()); + + using (var queryDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var reader = new DbReader(queryDb)) + { + var ordinary = reader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(ordinary, dbPath, dbPathExplicit: true); + Assert.Equal(initialIndexedAt, ordinary.IndexedAt); + Assert.Equal(initialNow.AddMinutes(1).UtcDateTime, ordinary.LatestModified); + Assert.Equal(initialNow.AddMinutes(2).UtcDateTime, ordinary.LastWorkspaceFreshenedAt); + var ordinaryEvaluation = StatusFreshnessEvaluator.Evaluate(ordinary, clock.GetUtcNow().UtcDateTime); + Assert.True( + ordinaryEvaluation.State == StatusFreshnessState.Fresh, + $"Expected fresh ordinary status, but got {ordinaryEvaluation.State} ({ordinaryEvaluation.Reason}); " + + $"gitHead={ordinary.GitHead}, indexedHead={ordinary.IndexedHeadSha}, " + + $"workspaceVerifiedHead={ordinary.WorkspaceVerifiedHeadSha}, gitDirty={ordinary.GitIsDirty}, " + + $"headChanged={ordinary.WorktreeHeadChanged}."); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + + ordinary.WorkspaceCheck = IndexFreshnessChecker.Check( + reader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + ordinary.IndexMatchesWorkspace = ordinary.WorkspaceCheck.MatchesWorkspace; + Assert.True(ordinary.WorkspaceCheck.Checked); + Assert.True(ordinary.WorkspaceCheck.MatchesWorkspace); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + } + + using var server = new McpServer( + dbPath, + "1.0.0-test", + dbPathExplicit: true, + serializeResponse: null, + authenticator: null, + toolFilter: null, + auditLog: null, + McpServer.DefaultMaxConcurrency, + clock); + var ordinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var checkedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index fresh", + ordinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + Assert.Contains( + "index fresh", + checkedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + } + finally + { + IndexCommandRunner.TimeProvider = TimeProvider.System; + QueryCommandRunner.TimeProvider = TimeProvider.System; + DeleteDirectory(projectRoot); + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void Run_NonGitRepo_DoesNotPersistIndexedHeadMetadata() { diff --git a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs new file mode 100644 index 000000000..11310c5eb --- /dev/null +++ b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs @@ -0,0 +1,106 @@ +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Tests; + +public class StatusFreshnessEvaluatorTests +{ + private static readonly DateTime IndexedAt = new(2030, 1, 2, 3, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ModifiedAt = IndexedAt.AddMinutes(1); + private static readonly DateTime FreshenedAt = IndexedAt.AddMinutes(2); + private static readonly DateTime EvaluatedAt = IndexedAt.AddMinutes(3); + + [Fact] + public void Evaluate_OrdinaryStatusUsesOnlyTrustedNoOpFreshening_Issue5227() + { + var status = CreateStatus(); + + var trusted = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Fresh, trusted.State); + Assert.Equal("workspace_freshened_after_latest_modified", trusted.Reason); + Assert.Equal("fresh", trusted.SummaryLabel); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(status, EvaluatedAt)); + + status.WorkspaceVerifiedHeadSha = null; + var unverified = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Unknown, unverified.State); + Assert.Equal("workspace_freshening_unverified", unverified.Reason); + Assert.Equal("unknown", unverified.SummaryLabel); + } + + [Fact] + public void Evaluate_OrdinaryStatusKeepsConservativeTimestampAndWorktreeBoundaries_Issue5227() + { + var status = CreateStatus(); + + status.LatestModified = FreshenedAt.AddSeconds(1); + var changedAfterFreshening = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, changedAfterFreshening.State); + Assert.Equal("latest_modified_after_freshness_evidence", changedAfterFreshening.Reason); + + status.LatestModified = ModifiedAt; + status.LastWorkspaceFreshenedAt = null; + var missingFresheningStamp = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, missingFresheningStamp.State); + + status.LastWorkspaceFreshenedAt = EvaluatedAt.AddSeconds(1); + var clockSkew = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.ClockSkew, clockSkew.State); + Assert.Equal("timestamp_in_future", clockSkew.Reason); + Assert.Equal("unknown", clockSkew.SummaryLabel); + + status.LastWorkspaceFreshenedAt = FreshenedAt; + status.WorktreeHeadChanged = true; + var headChanged = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.HeadChanged, headChanged.State); + Assert.Equal("head_changed", headChanged.Reason); + + status.WorktreeHeadChanged = false; + status.GitIsDirty = true; + var dirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, dirty.State); + Assert.Equal("worktree_dirty", dirty.Reason); + } + + [Theory] + [InlineData(false, false, false, "Unknown", "freshness_check_unavailable")] + [InlineData(true, true, false, "Fresh", "matched")] + [InlineData(true, false, false, "Stale", "changed_files")] + [InlineData(true, false, true, "HeadChanged", "head_changed")] + public void Evaluate_WorkspaceCheckUsesTheSameClassification( + bool checkedWorkspace, + bool matchesWorkspace, + bool headChanged, + string expectedState, + string expectedReason) + { + var check = new IndexFreshnessCheckResult + { + Checked = checkedWorkspace, + MatchesWorkspace = matchesWorkspace, + HeadChanged = headChanged, + Reason = expectedReason == "freshness_check_unavailable" ? "project_root_unavailable" : expectedReason, + }; + + var evaluation = StatusFreshnessEvaluator.Evaluate(check); + + Assert.Equal(expectedState, evaluation.State.ToString()); + Assert.Equal(expectedReason, evaluation.Reason); + } + + private static StatusResult CreateStatus() => new() + { + Files = 1, + Symbols = 1, + References = 0, + Languages = new Dictionary(StringComparer.Ordinal) { ["csharp"] = 1 }, + IndexedAt = IndexedAt, + LatestModified = ModifiedAt, + LastWorkspaceFreshenedAt = FreshenedAt, + GitHead = "0123456789abcdef", + GitIsDirty = false, + IndexedHeadSha = "0123456789abcdef", + WorkspaceVerifiedHeadSha = "0123456789abcdef", + WorktreeHeadChanged = false, + }; +} From 24933bc62562793d4a8c2a10d183b1e472d305f7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 06:52:36 +0900 Subject: [PATCH 2/6] Harden status freshness trust boundaries (#5227) --- DEVELOPER_GUIDE.md | 28 +++++--- README.md | 19 ++++-- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5227.fixed.md | 8 ++- src/CodeIndex/Cli/GitHelper.cs | 39 +++++++++++ .../Cli/WorkspaceMetadataEnricher.cs | 30 +++++++++ src/CodeIndex/Models/QueryResults.cs | 8 +++ .../Models/StatusFreshnessEvaluator.cs | 12 ++-- .../IndexCommandRunnerTests.cs | 67 +++++++++++++++++++ .../PathCompatibilityMatrixTests.cs | 8 +++ .../StatusFreshnessEvaluatorTests.cs | 18 +++++ 11 files changed, 216 insertions(+), 25 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index dc4d9ba62..75b738fe5 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1520,13 +1520,18 @@ Current stable codes and triggers: | Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. | Status freshness summaries are classified by one shared evaluator. An -authoritative `status --check` result takes precedence. Without that check, +authoritative `status --check` supplies the file-level result, while status-level +HEAD/branch drift takes precedence. Without that check, `last_workspace_freshened_at >= latest_modified` can prove a checksum-reused no-op update fresh only when the worktree is clean and the runtime, -workspace-verified, and latest-index HEAD SHAs all agree. Missing provenance and -future timestamps are `unknown`; a later modification, dirty worktree, or -changed HEAD remains conservative. Ordinary `head_freshness=head_current` -semantics remain distinct from the authoritative checked `fresh` value. +workspace-verified, and latest-index HEAD SHAs all agree, and Git reports no +`skip-worktree` or `assume-unchanged` entry that could hide a later change. +Missing provenance, hidden index state, and future timestamps are `unknown`; a +later modification, dirty worktree, or changed HEAD remains conservative. +Status-level HEAD/branch drift is evaluated before an authoritative file check, +so a same-SHA detached/branch transition cannot produce contradictory summaries. +Ordinary `head_freshness=head_current` semantics remain distinct from the +authoritative checked `fresh` value. `vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics. @@ -5697,13 +5702,16 @@ apply 時は `PRAGMA optimize` を実行します。 | newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 | status freshness summary は1つの共有 evaluator で分類します。authoritative な -`status --check` の結果を優先します。check がない場合、 +`status --check` は file-level の結果を提供し、status-level の HEAD / branch drift は +それより優先します。check がない場合、 `last_workspace_freshened_at >= latest_modified` が checksum 再利用 no-op update の freshness を証明できるのは、worktree が clean で、runtime、workspace 検証済み、直近 -index の HEAD SHA がすべて一致するときだけです。provenance 不足と未来 timestamp は -`unknown` とし、後続の変更、dirty worktree、HEAD 変更は保守的な判定を維持します。 -通常 status の `head_freshness=head_current` は、authoritative check 済みの `fresh` と -引き続き区別します。 +index の HEAD SHA がすべて一致し、後続変更を隠せる `skip-worktree` / `assume-unchanged` +entry が Git index に無い場合だけです。provenance 不足、隠れた index state、未来 timestamp +は `unknown` とし、後続の変更、dirty worktree、HEAD 変更は保守的な判定を維持します。 +status-level の HEAD / branch drift を authoritative file check より先に評価するため、同一 +SHA の detached / branch 遷移でも summary は矛盾しません。通常 status の +`head_freshness=head_current` は、authoritative check 済みの `fresh` と引き続き区別します。 `vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 diff --git a/README.md b/README.md index bd5be453f..c920f129c 100644 --- a/README.md +++ b/README.md @@ -200,10 +200,12 @@ visible here as a compact compatibility index. Ordinary status summaries use `last_workspace_freshened_at` as freshness evidence after a checksum-reused no-op update only when the runtime HEAD, `workspace_verified_head_sha`, and `indexed_head_sha` agree and the worktree is -clean. Missing provenance or a future timestamp yields `unknown`; an actual -workspace change remains `stale`. `status --check` performs the authoritative -workspace comparison, and CLI, workspace, and MCP summaries share the same -classification. +clean. Git index flags that can hide worktree changes (`skip-worktree` or +`assume-unchanged`) make this ordinary-status proof `unknown`. Missing provenance +or a future timestamp also yields `unknown`; an actual workspace change remains +`stale`. `status --check` performs the authoritative workspace comparison, and a +status-level HEAD/branch transition remains conservative even when file checks +match. CLI, workspace, and MCP summaries share the same classification. Persisted JSON subdocuments for `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, and @@ -454,9 +456,12 @@ field group を表に残します。 通常の status summary は、checksum 再利用による no-op update 後の鮮度証拠として `last_workspace_freshened_at` を使います。ただし runtime HEAD、 `workspace_verified_head_sha`、`indexed_head_sha` が一致し、worktree が clean な場合に -限ります。provenance が欠けている場合や timestamp が未来の場合は `unknown`、実際の -workspace 変更がある場合は引き続き `stale` です。`status --check` は authoritative な -workspace 比較を行い、CLI、workspace、MCP の summary は同じ分類を共有します。 +限ります。worktree 変更を隠せる Git index flag(`skip-worktree` または +`assume-unchanged`)がある場合、この通常 status の証拠は `unknown` です。provenance が +欠けている場合や timestamp が未来の場合も `unknown`、実際の workspace 変更がある場合は +引き続き `stale` です。`status --check` は authoritative な workspace 比較を行いますが、 +file check が一致しても status-level の HEAD / branch 遷移は保守的に扱います。CLI、 +workspace、MCP の summary は同じ分類を共有します。 `last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、 `last_failed_or_partial_index_run.file_errors` の永続化 JSON subdocument には、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 0eb06be8f..d0051a221 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack -- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs` and `IndexCommandRunnerTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, and changed HEADs. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, and MCP summary parity across both target frameworks. +- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `IndexCommandRunnerTests.cs`, and `PathCompatibilityMatrixTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, changed HEAD/branch provenance, and `skip-worktree` / `assume-unchanged` index flags. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, and MCP summary parity or an explicit conservative `unknown` across both target frameworks. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - Issue #5198 CODEOWNERS coverage belongs in `FileIndexerTests.cs`, `SymbolExtractorRepositoryMetadataTests.cs`, `QueryCommandRunnerFilesTests.cs`, `IndexCommandRunnerUpdateTests.cs`, `QueryCommandRunnerTests.cs`, and `McpServerToolsCallTests.cs`. Preserve the three case-sensitive Git-worktree-relative locations and the arbitrary-nested/case-variant/POSIX-literal-backslash negative cases, including scans rooted below the enclosing worktree; parser cases for full-line and inline comments, blank lines, whitespace, CRLF, rejection of unsupported escaped leading `#`, ownerless rules, user/team/email owners, malformed mentions, duplicate and overlapping patterns, invalid input, and persistable bounded diagnostics; ordered rule and owner child symbols; symbol-only capability guidance; full/scoped/delete indexing; branch-switch reconciliation; and removal from unknown-extension status diagnostics. @@ -1173,7 +1173,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs` と `IndexCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD 変更について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、MCP の summary が両 target framework で一致することを assertion します。 +- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`skip-worktree` / `assume-unchanged` index flag について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、MCP の summary が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - Issue #5198 の CODEOWNERS coverage は `FileIndexerTests.cs`、`SymbolExtractorRepositoryMetadataTests.cs`、`QueryCommandRunnerFilesTests.cs`、`IndexCommandRunnerUpdateTests.cs`、`QueryCommandRunnerTests.cs`、`McpServerToolsCallTests.cs` が担当します。case-sensitive な3つの Git worktree-relative location と、enclosing worktree より下を scan root にした場合を含む任意の nested file・大小文字違い file・POSIX の literal backslash file の negative case、full-line / inline comment・blank line・whitespace・CRLF・未対応である先頭 `#` の escape の拒否・ownerless rule・user/team/email owner・malformed mention・duplicate / overlapping pattern・不正 input・永続化可能な上限付き diagnostic の parser case、順序付き rule と owner child symbol、symbol-only capability guidance、full / scoped / delete indexing、branch-switch reconciliation、unknown-extension status diagnostic からの除外を維持してください。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md index d3cf938a6..add5a476b 100644 --- a/changelog.d/unreleased/5227.fixed.md +++ b/changelog.d/unreleased/5227.fixed.md @@ -4,11 +4,15 @@ issues: - 5227 affected: - src/CodeIndex/Models/StatusFreshnessEvaluator.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs - src/CodeIndex/Cli/QueryCommandRunner.Status.cs - src/CodeIndex/Cli/WorkspaceCommandRunner.cs - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs - tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs - README.md - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -16,8 +20,8 @@ affected: ## English -- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, closing the residual status contradiction from #3238 while preserving stale, missing-provenance, changed-HEAD, and clock-skew boundaries. +- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, closing the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. ## 日本語 -- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立し、#3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、HEAD 変更、clock skew の境界を維持します。 +- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立し、#3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index a0c3ac046..22d0f84aa 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1183,6 +1183,45 @@ private static string ParsePorcelainPath(string path) return paths; } + /// + /// Return whether tracked index flags can hide worktree changes from ordinary Git status. + /// Null means Git could not provide a trustworthy answer. Both skip-worktree and + /// assume-unchanged are visibility-limiting for freshness purposes (#5227). + /// 通常の Git status から worktree 変更を隠し得る tracked index flag の有無を返す。 + /// Git で確認できない場合は null。freshness 判定では skip-worktree と + /// assume-unchanged の両方を visibility 制限として扱う (#5227)。 + /// + internal static bool? TryHasWorktreeVisibilityLimitingIndexFlags( + string projectRoot, + CancellationToken cancellationToken = default) + { + var output = TryRunGit( + projectRoot, + gitEnvironmentOverrides: null, + cancellationToken, + "-c", + "core.quotePath=false", + "ls-files", + "-v"); + if (output == null) + return null; + + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length < 3 || line[1] != ' ') + continue; + + // `S` is skip-worktree. With `-v`, any lowercase tag means the + // assume-unchanged bit is set (including lowercase `s`). + // `S` は skip-worktree。`-v` の小文字 tag は assume-unchanged を示す。 + if (line[0] == 'S' || char.IsLower(line[0])) + return true; + } + + return false; + } + internal static string? TryGetRepositoryRoot( string projectPath, IReadOnlyDictionary? gitEnvironmentOverrides, diff --git a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs index 145fc2102..58049faa2 100644 --- a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs +++ b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs @@ -29,6 +29,10 @@ public static void Enrich( status.ProjectRoot = runtime.ProjectRoot; status.GitHead = runtime.RuntimeHead; status.GitIsDirty = runtime.IsDirty; + status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( + status, + runtime.ProjectRoot, + cancellationToken); status.WorktreeHeadChanged = ResolveHeadChanged( runtime.RuntimeHead, runtime.RuntimeBranch, @@ -53,6 +57,10 @@ public static void Enrich( status.ProjectRoot = metadata.ProjectRoot; status.GitHead = metadata.RuntimeHead; status.GitIsDirty = metadata.IsDirty; + status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( + status, + metadata.ProjectRoot, + cancellationToken); status.IndexedHeadCommit = metadata.LegacyIndexedHead; status.WorkspaceVerifiedHeadSha = metadata.WorkspaceVerifiedHead; status.WorktreeHeadChanged = metadata.HeadChanged; @@ -63,6 +71,28 @@ public static void Enrich( status.CommitsAheadOfIndexedHead = GitHelper.TryCountCommitsAhead(metadata.ProjectRoot, status.IndexedHeadSha, cancellationToken); } + private static bool? ResolveGitIndexVisibility( + StatusResult status, + string? projectRoot, + CancellationToken cancellationToken) + { + if (projectRoot == null + || !status.IndexedAt.HasValue + || !status.LatestModified.HasValue + || !status.LastWorkspaceFreshenedAt.HasValue + || status.IndexedAt.Value >= status.LatestModified.Value + || status.LastWorkspaceFreshenedAt.Value < status.LatestModified.Value) + { + return null; + } + + // Keep ordinary status cheap outside the checksum-reused no-op case. Only the + // fallback proof needs to rule out index flags that can mask later changes. + // checksum 再利用 no-op の fallback 証拠を使う場合だけ index flag を確認し、 + // それ以外の通常 status には追加の Git 列挙を行わない。 + return GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(projectRoot, cancellationToken); + } + public static void Enrich( RepoMapResult map, string dbPath, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 03aebd7df..260951e80 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1628,6 +1628,14 @@ public class StatusResult public StatusSqliteConnectionPolicy SqliteConnectionPolicy { get; set; } = new(); public string? GitHead { get; set; } public bool? GitIsDirty { get; set; } + // Ordinary status consults this internal runtime signal only when a no-op freshening + // stamp would otherwise prove freshness. False means Git confirmed that no tracked + // entry uses skip-worktree or assume-unchanged; true/null keeps that proof unverified. + // no-op freshening stamp を信頼する場合だけ使う runtime 内部 signal。 + // false は skip-worktree / assume-unchanged が無いことを Git で確認済み、 + // true/null は worktree 変更を隠せるため未検証として扱う。 + [JsonIgnore] + internal bool? GitIndexMayHideWorktreeChanges { get; set; } /// /// Best-effort Linux mandatory-access-control profile for the running process, such as /// `apparmor:snap.cdidx.cdidx` or `selinux:user_u:user_r:user_t:s0`. Null on non-Linux diff --git a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs index ac3f67021..da9b15824 100644 --- a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs +++ b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs @@ -31,12 +31,12 @@ internal static class StatusFreshnessEvaluator { public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime utcNow) { - if (status.WorkspaceCheck is not null) - return Evaluate(status.WorkspaceCheck); - if (status.WorktreeHeadChanged == true) return new(StatusFreshnessState.HeadChanged, "head_changed"); + if (status.WorkspaceCheck is not null) + return Evaluate(status.WorkspaceCheck); + if (status.GitIsDirty == true) return new(StatusFreshnessState.Stale, "worktree_dirty"); @@ -84,8 +84,12 @@ public static StatusFreshnessEvaluation Evaluate(IndexFreshnessCheckResult check private static bool HasTrustedFresheningContext(StatusResult status) { - if (status.WorktreeHeadChanged != false || status.GitIsDirty != false) + if (status.WorktreeHeadChanged != false + || status.GitIsDirty != false + || status.GitIndexMayHideWorktreeChanges != false) + { return false; + } var runtimeHead = NullIfWhiteSpace(status.GitHead); var workspaceVerifiedHead = NullIfWhiteSpace(status.WorkspaceVerifiedHeadSha); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 9da46a8ad..ed82dab3d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6484,6 +6484,73 @@ public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgre Assert.Contains( "index fresh", checkedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + + RunGit(projectRoot, "update-index", "--skip-worktree", "app.py"); + File.WriteAllText(sourcePath, "print('changed after freshening')\n"); + clock.Advance(TimeSpan.FromMinutes(1)); + + using (var hiddenChangeDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var hiddenChangeReader = new DbReader(hiddenChangeDb)) + { + var hiddenChange = hiddenChangeReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(hiddenChange, dbPath, dbPathExplicit: true); + Assert.False(hiddenChange.GitIsDirty); + Assert.True(hiddenChange.GitIndexMayHideWorktreeChanges); + Assert.Contains( + "index unknown", + QueryCommandRunner.BuildStatusSummary(hiddenChange, clock.GetUtcNow().UtcDateTime)); + + hiddenChange.WorkspaceCheck = IndexFreshnessChecker.Check( + hiddenChangeReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.False(hiddenChange.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(hiddenChange, clock.GetUtcNow().UtcDateTime)); + } + + var hiddenOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var hiddenCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index unknown", + hiddenOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + Assert.Contains( + "index stale", + hiddenCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + + RunGit(projectRoot, "update-index", "--no-skip-worktree", "app.py"); + RunGit(projectRoot, "checkout", "--", "app.py"); + RunGit(projectRoot, "checkout", "--detach", "HEAD"); + + using (var detachedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var detachedReader = new DbReader(detachedDb)) + { + var detached = detachedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(detached, dbPath, dbPathExplicit: true); + Assert.False(detached.GitIsDirty); + Assert.True(detached.WorktreeHeadChanged); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(detached, clock.GetUtcNow().UtcDateTime)); + + detached.WorkspaceCheck = IndexFreshnessChecker.Check( + detachedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.True(detached.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(detached, clock.GetUtcNow().UtcDateTime)); + } + + var detachedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index stale", + detachedCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); } finally { diff --git a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs index c88acb409..d8dbf6ddc 100644 --- a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs +++ b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs @@ -299,6 +299,8 @@ public void GitSkipWorktreeMatrix_ReturnsNormalizedSparsePaths() TestProjectHelper.RunGit(workspace.Root, "add", "src/App.cs", "generated/Skip.cs"); TestProjectHelper.RunGit(workspace.Root, "commit", "-m", "seed"); + Assert.False(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); + TestProjectHelper.RunGit(workspace.Root, "update-index", "--skip-worktree", "generated/Skip.cs"); var skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(workspace.Root); @@ -306,6 +308,12 @@ public void GitSkipWorktreeMatrix_ReturnsNormalizedSparsePaths() Assert.NotNull(skipWorktreePaths); Assert.Contains("generated/Skip.cs", skipWorktreePaths); Assert.DoesNotContain(skipWorktreePaths!, path => path.Contains('\\', StringComparison.Ordinal)); + Assert.True(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); + + TestProjectHelper.RunGit(workspace.Root, "update-index", "--no-skip-worktree", "generated/Skip.cs"); + TestProjectHelper.RunGit(workspace.Root, "update-index", "--assume-unchanged", "src/App.cs"); + + Assert.True(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); } private sealed class MatrixWorkspace : IDisposable diff --git a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs index 11310c5eb..26583593d 100644 --- a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs +++ b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs @@ -21,6 +21,12 @@ public void Evaluate_OrdinaryStatusUsesOnlyTrustedNoOpFreshening_Issue5227() Assert.Equal("fresh", trusted.SummaryLabel); Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(status, EvaluatedAt)); + status.GitIndexMayHideWorktreeChanges = true; + var hiddenIndexState = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Unknown, hiddenIndexState.State); + Assert.Equal("workspace_freshening_unverified", hiddenIndexState.Reason); + + status.GitIndexMayHideWorktreeChanges = false; status.WorkspaceVerifiedHeadSha = null; var unverified = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); Assert.Equal(StatusFreshnessState.Unknown, unverified.State); @@ -55,6 +61,17 @@ public void Evaluate_OrdinaryStatusKeepsConservativeTimestampAndWorktreeBoundari Assert.Equal(StatusFreshnessState.HeadChanged, headChanged.State); Assert.Equal("head_changed", headChanged.Reason); + status.WorkspaceCheck = new IndexFreshnessCheckResult + { + Checked = true, + MatchesWorkspace = true, + Reason = "matched", + }; + var checkedHeadChanged = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.HeadChanged, checkedHeadChanged.State); + Assert.Equal("head_changed", checkedHeadChanged.Reason); + + status.WorkspaceCheck = null; status.WorktreeHeadChanged = false; status.GitIsDirty = true; var dirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); @@ -99,6 +116,7 @@ public void Evaluate_WorkspaceCheckUsesTheSameClassification( LastWorkspaceFreshenedAt = FreshenedAt, GitHead = "0123456789abcdef", GitIsDirty = false, + GitIndexMayHideWorktreeChanges = false, IndexedHeadSha = "0123456789abcdef", WorkspaceVerifiedHeadSha = "0123456789abcdef", WorktreeHeadChanged = false, From 44948901a4f32b9b0a4a2993ae40a7941d3a3610 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 07:41:47 +0900 Subject: [PATCH 3/6] Resolve adversarial status freshness findings (#5227) --- DEVELOPER_GUIDE.md | 27 +++++--- README.md | 25 ++++--- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5227.fixed.md | 7 +- src/CodeIndex/Cli/GitHelper.cs | 9 ++- .../Cli/QueryCommandRunner.Status.cs | 20 ++++-- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 20 +++++- .../Cli/WorkspaceMetadataEnricher.cs | 2 +- .../Database/DbReader.WorkspaceHealth.cs | 18 ++++- .../Mcp/McpToolHandlers.Query.Status.cs | 20 ++++-- .../Models/StatusFreshnessEvaluator.cs | 13 +++- tests/CodeIndex.Tests/GitHelperTests.cs | 13 ++++ .../IndexCommandRunnerTests.cs | 67 ++++++++++++++++++- .../WorkspaceCommandRunnerTests.cs | 43 ++++++++++++ 14 files changed, 240 insertions(+), 48 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 75b738fe5..eeb0ee4fa 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1526,12 +1526,15 @@ HEAD/branch drift takes precedence. Without that check, no-op update fresh only when the worktree is clean and the runtime, workspace-verified, and latest-index HEAD SHAs all agree, and Git reports no `skip-worktree` or `assume-unchanged` entry that could hide a later change. -Missing provenance, hidden index state, and future timestamps are `unknown`; a -later modification, dirty worktree, or changed HEAD remains conservative. -Status-level HEAD/branch drift is evaluated before an authoritative file check, -so a same-SHA detached/branch transition cannot produce contradictory summaries. -Ordinary `head_freshness=head_current` semantics remain distinct from the -authoritative checked `fresh` value. +The Git dirtiness probe explicitly requests all untracked files, overriding a +repository-level `status.showUntrackedFiles=no` setting. Missing provenance, +hidden index state, and future timestamps are `unknown`; a later modification, +dirty worktree, or changed HEAD remains conservative. Status-level HEAD/branch +drift is evaluated before an authoritative file check and is propagated into +checked failures, exit status, and workspace member health, so a same-SHA +detached/branch transition cannot produce contradictory outcomes. Ordinary +`head_freshness=head_current` semantics remain distinct from the authoritative +checked `fresh` value. `vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics. @@ -5707,11 +5710,13 @@ status freshness summary は1つの共有 evaluator で分類します。authori `last_workspace_freshened_at >= latest_modified` が checksum 再利用 no-op update の freshness を証明できるのは、worktree が clean で、runtime、workspace 検証済み、直近 index の HEAD SHA がすべて一致し、後続変更を隠せる `skip-worktree` / `assume-unchanged` -entry が Git index に無い場合だけです。provenance 不足、隠れた index state、未来 timestamp -は `unknown` とし、後続の変更、dirty worktree、HEAD 変更は保守的な判定を維持します。 -status-level の HEAD / branch drift を authoritative file check より先に評価するため、同一 -SHA の detached / branch 遷移でも summary は矛盾しません。通常 status の -`head_freshness=head_current` は、authoritative check 済みの `fresh` と引き続き区別します。 +entry が Git index に無い場合だけです。Git の dirtiness probe は未追跡 file を明示的に +すべて要求し、repository の `status.showUntrackedFiles=no` 設定を上書きします。provenance +不足、隠れた index state、未来 timestamp は `unknown` とし、後続の変更、dirty worktree、 +HEAD 変更は保守的な判定を維持します。status-level の HEAD / branch drift は authoritative +file check より先に評価して checked failure、終了 status、workspace member health にも +伝播するため、同一 SHA の detached / branch 遷移でも outcome は矛盾しません。通常 status +の `head_freshness=head_current` は、authoritative check 済みの `fresh` と引き続き区別します。 `vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 diff --git a/README.md b/README.md index c920f129c..a899c4386 100644 --- a/README.md +++ b/README.md @@ -201,11 +201,14 @@ Ordinary status summaries use `last_workspace_freshened_at` as freshness evidenc after a checksum-reused no-op update only when the runtime HEAD, `workspace_verified_head_sha`, and `indexed_head_sha` agree and the worktree is clean. Git index flags that can hide worktree changes (`skip-worktree` or -`assume-unchanged`) make this ordinary-status proof `unknown`. Missing provenance -or a future timestamp also yields `unknown`; an actual workspace change remains -`stale`. `status --check` performs the authoritative workspace comparison, and a -status-level HEAD/branch transition remains conservative even when file checks -match. CLI, workspace, and MCP summaries share the same classification. +`assume-unchanged`) make this ordinary-status proof `unknown`, while the Git +dirtiness probe always includes untracked files even when +`status.showUntrackedFiles=no`. Missing provenance or a future timestamp also +yields `unknown`; an actual workspace change remains `stale`. `status --check` +performs the authoritative workspace comparison, and a status-level HEAD/branch +transition remains conservative even when file checks match. That transition also +fails the checked status and member-health result; CLI, workspace, and MCP status +surfaces therefore share the same classification and check outcome. Persisted JSON subdocuments for `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, and @@ -457,11 +460,13 @@ field group を表に残します。 `last_workspace_freshened_at` を使います。ただし runtime HEAD、 `workspace_verified_head_sha`、`indexed_head_sha` が一致し、worktree が clean な場合に 限ります。worktree 変更を隠せる Git index flag(`skip-worktree` または -`assume-unchanged`)がある場合、この通常 status の証拠は `unknown` です。provenance が -欠けている場合や timestamp が未来の場合も `unknown`、実際の workspace 変更がある場合は -引き続き `stale` です。`status --check` は authoritative な workspace 比較を行いますが、 -file check が一致しても status-level の HEAD / branch 遷移は保守的に扱います。CLI、 -workspace、MCP の summary は同じ分類を共有します。 +`assume-unchanged`)がある場合、この通常 status の証拠は `unknown` です。また Git の +dirtiness probe は `status.showUntrackedFiles=no` の設定時も未追跡 file を必ず含めます。 +provenance が欠けている場合や timestamp が未来の場合も `unknown`、実際の workspace +変更がある場合は引き続き `stale` です。`status --check` は authoritative な workspace +比較を行いますが、file check が一致しても status-level の HEAD / branch 遷移は保守的に +扱います。この遷移は checked status と member-health result も失敗させるため、CLI、 +workspace、MCP の status surface は分類と check outcome の両方を共有します。 `last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、 `last_failed_or_partial_index_run.file_errors` の永続化 JSON subdocument には、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index d0051a221..d4f82ae81 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack -- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `IndexCommandRunnerTests.cs`, and `PathCompatibilityMatrixTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, changed HEAD/branch provenance, and `skip-worktree` / `assume-unchanged` index flags. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, and MCP summary parity or an explicit conservative `unknown` across both target frameworks. +- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, and `WorkspaceCommandRunnerTests.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. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, workspace member health/exit status, and MCP classification/check parity or an explicit conservative `unknown` across both target frameworks. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. - Issue #5198 CODEOWNERS coverage belongs in `FileIndexerTests.cs`, `SymbolExtractorRepositoryMetadataTests.cs`, `QueryCommandRunnerFilesTests.cs`, `IndexCommandRunnerUpdateTests.cs`, `QueryCommandRunnerTests.cs`, and `McpServerToolsCallTests.cs`. Preserve the three case-sensitive Git-worktree-relative locations and the arbitrary-nested/case-variant/POSIX-literal-backslash negative cases, including scans rooted below the enclosing worktree; parser cases for full-line and inline comments, blank lines, whitespace, CRLF, rejection of unsupported escaped leading `#`, ownerless rules, user/team/email owners, malformed mentions, duplicate and overlapping patterns, invalid input, and persistable bounded diagnostics; ordered rule and owner child symbols; symbol-only capability guidance; full/scoped/delete indexing; branch-switch reconciliation; and removal from unknown-extension status diagnostics. @@ -1173,7 +1173,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`skip-worktree` / `assume-unchanged` index flag について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、MCP の summary が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。 +- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、workspace member health / 終了 status、MCP の分類と check が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 - Issue #5198 の CODEOWNERS coverage は `FileIndexerTests.cs`、`SymbolExtractorRepositoryMetadataTests.cs`、`QueryCommandRunnerFilesTests.cs`、`IndexCommandRunnerUpdateTests.cs`、`QueryCommandRunnerTests.cs`、`McpServerToolsCallTests.cs` が担当します。case-sensitive な3つの Git worktree-relative location と、enclosing worktree より下を scan root にした場合を含む任意の nested file・大小文字違い file・POSIX の literal backslash file の negative case、full-line / inline comment・blank line・whitespace・CRLF・未対応である先頭 `#` の escape の拒否・ownerless rule・user/team/email owner・malformed mention・duplicate / overlapping pattern・不正 input・永続化可能な上限付き diagnostic の parser case、順序付き rule と owner child symbol、symbol-only capability guidance、full / scoped / delete indexing、branch-switch reconciliation、unknown-extension status diagnostic からの除外を維持してください。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md index add5a476b..aaf66ce51 100644 --- a/changelog.d/unreleased/5227.fixed.md +++ b/changelog.d/unreleased/5227.fixed.md @@ -9,10 +9,13 @@ affected: - src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs - src/CodeIndex/Cli/QueryCommandRunner.Status.cs - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - src/CodeIndex/Database/DbReader.WorkspaceHealth.cs - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs - tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs + - tests/CodeIndex.Tests/GitHelperTests.cs - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs - README.md - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -20,8 +23,8 @@ affected: ## English -- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, closing the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. +- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier and check outcome. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, while untracked-file detection overrides `status.showUntrackedFiles=no` and same-commit branch drift fails checked status and workspace health. This closes the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. ## 日本語 -- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立し、#3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 +- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier と check outcome を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立する一方、未追跡 file の検出は `status.showUntrackedFiles=no` を上書きし、同一 commit の branch drift は checked status と workspace health を失敗させます。これにより #3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 22d0f84aa..509d9acb5 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1110,7 +1110,14 @@ internal static bool ResolveIgnoreCase( /// public static WorktreeStatus? TryGetWorktreeStatus(string projectRoot, CancellationToken cancellationToken = default) { - var output = TryRunGit(projectRoot, cancellationToken, "-c", "core.quotePath=false", "status", "--porcelain"); + var output = TryRunGit( + projectRoot, + cancellationToken, + "-c", + "core.quotePath=false", + "status", + "--porcelain", + "--untracked-files=all"); if (output == null) return null; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index dabd3d470..22f947d3f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -137,7 +137,9 @@ public static int RunStatus( cancellationToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(options.DbPath)); status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked - ? status.WorkspaceCheck.MatchesWorkspace + ? StatusFreshnessEvaluator.Evaluate( + status.WorkspaceCheck, + status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = (long)Math.Round(staleAfter.Value.TotalSeconds, MidpointRounding.AwayFromZero); status.QueryContext = new StatusQueryContext @@ -1004,13 +1006,19 @@ private static IReadOnlyList BuildStatusCheckFailures(Status { failures.Add(new StatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); } - else if (!status.WorkspaceCheck.MatchesWorkspace) + else { var check = status.WorkspaceCheck; - failures.Add(new StatusCheckFailure( - "workspace_stale", - true, - $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + var freshness = StatusFreshnessEvaluator.Evaluate( + check, + status.WorktreeHeadChanged); + if (freshness.State != StatusFreshnessState.Fresh) + { + failures.Add(new StatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={freshness.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } } } diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 5d16f8d52..56b085f05 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -430,7 +430,19 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( projectRoot, cancellationToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); - var freshnessEvaluation = StatusFreshnessEvaluator.Evaluate(freshness); + var worktreeHeadChanged = WorkspaceMetadataEnricher.ResolveHeadChanged( + GitHelper.TryGetHeadCommit(projectRoot, cancellationToken), + GitHelper.TryGetHeadBranch(projectRoot, cancellationToken), + snapshot.WorkspaceVerifiedHeadSha, + snapshot.IndexedHeadSha, + snapshot.IndexedHeadBranch, + snapshot.IndexedHeadBranchStampPresent, + snapshot.IndexedHeadCommit, + snapshot.IndexedHeadCommitBranch, + snapshot.IndexedHeadCommitBranchStampPresent); + var freshnessEvaluation = StatusFreshnessEvaluator.Evaluate( + freshness, + worktreeHeadChanged); var status = "ready"; var reason = "ready"; if (freshnessEvaluation.State == StatusFreshnessState.Unknown) @@ -475,8 +487,10 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( projectRoot, dbPath), SchemaCompatible: true, - IndexMatchesWorkspace: freshness.Checked ? freshness.MatchesWorkspace : null, - FreshnessReason: freshness.Reason, + IndexMatchesWorkspace: freshness.Checked + ? freshnessEvaluation.State == StatusFreshnessState.Fresh + : null, + FreshnessReason: freshnessEvaluation.Reason, IndexedAt: snapshot.IndexedAt, LatestModified: snapshot.LatestModified, GraphTableAvailable: snapshot.GraphTableAvailable, diff --git a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs index 58049faa2..1b59e92a0 100644 --- a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs +++ b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs @@ -240,7 +240,7 @@ private static RuntimeWorkspaceMetadata ResolveRuntime( GitHelper.TryIsWorktreeDirty(projectRoot, cancellationToken)); } - private static bool? ResolveHeadChanged( + internal static bool? ResolveHeadChanged( string? runtimeHead, string? runtimeBranch, string? workspaceVerifiedHead, diff --git a/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs b/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs index 6f416facf..7d90bd9e2 100644 --- a/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs +++ b/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs @@ -7,7 +7,14 @@ internal sealed record WorkspaceIndexHealthSnapshot( bool GraphDataCurrent, bool ReferenceGraphComplete, bool IndexComplete, - bool IndexNewerThanReader); + bool IndexNewerThanReader, + string? IndexedHeadCommit, + string? IndexedHeadCommitBranch, + bool IndexedHeadCommitBranchStampPresent, + string? WorkspaceVerifiedHeadSha, + string? IndexedHeadSha, + string? IndexedHeadBranch, + bool IndexedHeadBranchStampPresent); public partial class DbReader { @@ -34,6 +41,13 @@ internal WorkspaceIndexHealthSnapshot GetWorkspaceIndexHealth() persistedReadiness.GraphDataCurrent, persistedReadiness.ReferenceGraphComplete, persistedReadiness.IndexComplete, - _indexNewerThanReader); + _indexNewerThanReader, + TryGetMetaStringInternal(DbContext.IndexedHeadCommitMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadCommitBranchMetaKey), + HasMetaKeyInternal(DbContext.IndexedHeadCommitBranchMetaKey), + TryGetMetaStringInternal(DbContext.WorkspaceVerifiedHeadShaMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadShaMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadBranchMetaKey), + HasMetaKeyInternal(DbContext.IndexedHeadBranchMetaKey)); }); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index e6592ab4c..67633646d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -68,7 +68,9 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) requestToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked - ? status.WorkspaceCheck.MatchesWorkspace + ? StatusFreshnessEvaluator.Evaluate( + status.WorkspaceCheck, + status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = staleAfterSeconds; if (status.IndexedAt.HasValue) @@ -342,13 +344,19 @@ private static IReadOnlyList BuildMcpStatusCheckFailures( { failures.Add(new McpStatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); } - else if (!status.WorkspaceCheck.MatchesWorkspace) + else { var check = status.WorkspaceCheck; - failures.Add(new McpStatusCheckFailure( - "workspace_stale", - true, - $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + var freshness = StatusFreshnessEvaluator.Evaluate( + check, + status.WorktreeHeadChanged); + if (freshness.State != StatusFreshnessState.Fresh) + { + failures.Add(new McpStatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={freshness.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } } } diff --git a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs index da9b15824..b988dec89 100644 --- a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs +++ b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs @@ -31,12 +31,12 @@ internal static class StatusFreshnessEvaluator { public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime utcNow) { + if (status.WorkspaceCheck is not null) + return Evaluate(status.WorkspaceCheck, status.WorktreeHeadChanged); + if (status.WorktreeHeadChanged == true) return new(StatusFreshnessState.HeadChanged, "head_changed"); - if (status.WorkspaceCheck is not null) - return Evaluate(status.WorkspaceCheck); - if (status.GitIsDirty == true) return new(StatusFreshnessState.Stale, "worktree_dirty"); @@ -72,7 +72,14 @@ public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime u } public static StatusFreshnessEvaluation Evaluate(IndexFreshnessCheckResult check) + => Evaluate(check, worktreeHeadChanged: null); + + public static StatusFreshnessEvaluation Evaluate( + IndexFreshnessCheckResult check, + bool? worktreeHeadChanged) { + if (worktreeHeadChanged == true) + return new(StatusFreshnessState.HeadChanged, "head_changed"); if (!check.Checked) return new(StatusFreshnessState.Unknown, "freshness_check_unavailable"); if (check.MatchesWorkspace) diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 3ec5181d8..1ed5880de 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -1524,6 +1524,19 @@ public void TryIsWorktreeDirty_DetectsModifiedFiles() Assert.True(GitHelper.TryIsWorktreeDirty(repoDir)); } + [ExternalProcessFact] + public void TryIsWorktreeDirty_ForcesUntrackedVisibilityDespiteGitConfig_Issue5227() + { + var repoDir = CreateGitRepo(); + RunGit(repoDir, "commit", "--allow-empty", "-m", "initial"); + RunGit(repoDir, "config", "status.showUntrackedFiles", "no"); + + File.WriteAllText(Path.Combine(repoDir, "untracked.txt"), "indexed outside git\n"); + + Assert.True(GitHelper.TryIsWorktreeDirty(repoDir)); + Assert.True(GitHelper.TryGetWorktreeStatus(repoDir)?.IsDirty); + } + [ExternalProcessFact] public void TryGetWorktreeStatus_DetectsUnresolvedMergeFiles() { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index ed82dab3d..ab92af3c8 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6546,11 +6546,76 @@ public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgre QueryCommandRunner.BuildStatusSummary(detached, clock.GetUtcNow().UtcDateTime)); } + var (detachedStatusExitCode, detachedStatusJson) = RunStatusAndCaptureJson( + ["--db", dbPath, "--check", "--json"]); + Assert.Equal(1, detachedStatusExitCode); + Assert.False(detachedStatusJson.GetProperty("index_matches_workspace").GetBoolean()); + Assert.True(detachedStatusJson.GetProperty("workspace_check").GetProperty("matches_workspace").GetBoolean()); + Assert.Contains( + detachedStatusJson.GetProperty("failed_checks").EnumerateArray(), + failure => failure.GetString() == "workspace_stale"); + var detachedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + var detachedMcpStatus = detachedCheckedMcpResponse["result"]!["structuredContent"]!; + Assert.Contains( + "index stale", + detachedMcpStatus["summary"]!.GetValue()); + Assert.False(detachedMcpStatus["index_matches_workspace"]!.GetValue()); + Assert.Contains( + detachedMcpStatus["failed_checks"]!.AsArray(), + failure => failure!.GetValue() == "workspace_stale"); + + RunGit(projectRoot, "checkout", "main"); + RunGit(projectRoot, "config", "status.showUntrackedFiles", "no"); + var untrackedPath = Path.Combine(projectRoot, "untracked.py"); + File.WriteAllText(untrackedPath, "print('untracked v1')\n"); + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(3).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + Assert.Equal( + CommandExitCodes.Success, + RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]).ExitCode); + + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(6).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + Assert.Equal( + CommandExitCodes.Success, + RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]).ExitCode); + + File.WriteAllText(untrackedPath, "print('untracked v2')\n"); + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(8).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + + using (var untrackedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var untrackedReader = new DbReader(untrackedDb)) + { + var ordinary = untrackedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(ordinary, dbPath, dbPathExplicit: true); + Assert.True(ordinary.GitIsDirty); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + + ordinary.WorkspaceCheck = IndexFreshnessChecker.Check( + untrackedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.False(ordinary.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + } + + var untrackedOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var untrackedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index stale", + untrackedOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); Assert.Contains( "index stale", - detachedCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + untrackedCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); } finally { diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index e204d8cc8..204f660a8 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -456,6 +456,49 @@ public void WorkspaceStatus_PropagatesCancellationToMemberHealthScan_Issue4726() } } + [Fact] + public void WorkspaceStatusCheck_ReportsSameCommitBranchDriftAsStale_Issue5227() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_status_head_drift_5227"); + var root = project.Root; + File.WriteAllText(Path.Combine(root, "App.cs"), "class App {}\n"); + File.WriteAllText( + Path.Combine(root, "cdidx.workspace.json"), + """{ "members": ["."] }"""); + TestProjectHelper.RunGit(root, "init"); + TestProjectHelper.RunGit(root, "config", "user.email", "tests@example.com"); + TestProjectHelper.RunGit(root, "config", "user.name", "CodeIndex Tests"); + TestProjectHelper.RunGit(root, "checkout", "-B", "main"); + TestProjectHelper.RunGit(root, "add", "App.cs", "cdidx.workspace.json"); + TestProjectHelper.RunGit(root, "commit", "--no-gpg-sign", "-m", "initial"); + IndexProject(root); + TestProjectHelper.RunGit(root, "checkout", "--detach", "HEAD"); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.StaleIndex, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var health = document.RootElement.GetProperty("members")[0].GetProperty("index_health"); + Assert.Equal("stale", health.GetProperty("status").GetString()); + Assert.Equal("head_changed", health.GetProperty("reason").GetString()); + Assert.False(health.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("head_changed", health.GetProperty("freshness_reason").GetString()); + var summary = document.RootElement.GetProperty("member_health_summary"); + Assert.Equal("degraded", summary.GetProperty("status").GetString()); + Assert.Equal(CommandExitCodes.StaleIndex, summary.GetProperty("check_exit_code").GetInt32()); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + [Fact] public void CheckedInWorkspaceManifest_ResolvesExistingProjectMembers_Issue4476() { From ebdde1e732699dae21ef5338fb024249d2595223 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 31 Aug 2026 21:53:50 +0900 Subject: [PATCH 4/6] Stabilize coverage performance smoke budget (#5243) --- .github/scripts/run-dotnet-tests.ps1 | 4 ++++ tests/CodeIndex.Tests/PerformanceTests.cs | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/scripts/run-dotnet-tests.ps1 b/.github/scripts/run-dotnet-tests.ps1 index 509eb5d0e..2639d1843 100644 --- a/.github/scripts/run-dotnet-tests.ps1 +++ b/.github/scripts/run-dotnet-tests.ps1 @@ -76,6 +76,10 @@ function Invoke-TestRun { $runArgs += @("--filter", $TestFilter) } + # Performance budgets must distinguish instrumented runs from ordinary retries. + # performance budget が coverage 計測中と通常の retry を区別できるようにする。 + $env:CODEINDEX_TEST_COVERAGE = if ($IncludeCoverage) { "true" } else { "false" } + [int]$failureLogTailLineLimit = 2000 $retainedOutputTail = [System.Collections.Generic.Queue[string]]::new($failureLogTailLineLimit) [long]$totalOutputLineCount = 0 diff --git a/tests/CodeIndex.Tests/PerformanceTests.cs b/tests/CodeIndex.Tests/PerformanceTests.cs index d046a805e..d6a73f321 100644 --- a/tests/CodeIndex.Tests/PerformanceTests.cs +++ b/tests/CodeIndex.Tests/PerformanceTests.cs @@ -370,11 +370,17 @@ public void CiPerformanceSmoke_IndexAndSearchSmallFixture_StaysWithinBudget() Assert.True(chunks >= 120, $"Expected at least one chunk per file, got {chunks}"); Assert.True(symbols >= 240, $"Expected class and method symbols from the smoke fixture, got {symbols}"); Assert.True(references > 0, "Expected reference rows from the smoke fixture."); - // Hosted Windows runners have wider filesystem and process-scheduling variance. - // Keep a bounded platform budget without weakening other lanes. - // hosted Windows runner は filesystem / process scheduling の変動幅が大きいため、 - // 他 lane の基準は維持したまま platform 別の上限を設定する。 - var indexBudget = OperatingSystem.IsWindows() + // Hosted Windows runners have wider filesystem and process-scheduling variance, + // while coverage instrumentation adds deterministic hot-path overhead. Keep the + // strict budget for every uninstrumented non-Windows run, including CI retries. + // hosted Windows runner は filesystem / process scheduling の変動幅が大きく、coverage + // instrumentation には hot path の追加 overhead がある。CI retry を含む coverage なしの + // 非 Windows 実行では厳しい上限を維持する。 + var coverageInstrumentationActive = string.Equals( + Environment.GetEnvironmentVariable("CODEINDEX_TEST_COVERAGE"), + "true", + StringComparison.OrdinalIgnoreCase); + var indexBudget = OperatingSystem.IsWindows() || coverageInstrumentationActive ? TimeSpan.FromSeconds(45) : TimeSpan.FromSeconds(20); Assert.True( From 2ba965221e96e3c0ed385329d5a622f42a5659f6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 1 Sep 2026 02:29:35 +0900 Subject: [PATCH 5/6] Resolve status freshness review findings (#5227) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5227.fixed.md | 2 + src/CodeIndex/Cli/GitHelper.cs | 61 ++++++++---- src/CodeIndex/Cli/GitProcessRunner.cs | 98 +++++++++++++++++++ src/CodeIndex/Models/QueryResults.cs | 42 ++++---- tests/CodeIndex.Tests/GitHelperTests.cs | 4 +- .../CodeIndex.Tests/GitProcessRunnerTests.cs | 42 ++++++++ .../StatusFreshnessEvaluatorTests.cs | 5 + 8 files changed, 220 insertions(+), 38 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 008568f66..ab11be3d5 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack -- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, and `WorkspaceCommandRunnerTests.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. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, workspace member health/exit status, and MCP classification/check parity or an explicit conservative `unknown` across both target frameworks. +- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, and `WorkspaceCommandRunnerTests.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 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. - Issue #5226 impact-count coverage belongs in `DbReaderIssue5226Tests.cs`, `QueryCommandRunnerIssue5226Tests.cs`, `QueryCommandRunnerImpactTests.cs`, and `McpServerIssue5226Tests.cs`. Keep zero, one, above-default, multi-hop, include/exclude-filter, incomplete-graph, dedicated safety-cap, heuristic-fallback, cross-language SQL-readiness, and ordinary row-limited cases together. Human, JSON, compact, and MCP count modes must ignore the presentation limit while preserving authoritative/lower-bound metadata and omitting count-only result rows; capped human output must retain numeric stdout and warn with the reason on stderr. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. @@ -1174,7 +1174,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag について、決定的な timestamp 境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、workspace member health / 終了 status、MCP の分類と check が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。 +- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag、および 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 します。 - Issue #5226 の impact count coverage は `DbReaderIssue5226Tests.cs`、`QueryCommandRunnerIssue5226Tests.cs`、`QueryCommandRunnerImpactTests.cs`、`McpServerIssue5226Tests.cs` が担当します。0件、1件、既定上限超過、multi-hop、include / exclude filter、不完全 graph、専用 safety cap、heuristic fallback、cross-language の SQL readiness、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。cap 到達時の human 出力は stdout を数値のみのまま保ち、stderr に理由付き warning を出します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md index aaf66ce51..2bb8dbd3a 100644 --- a/changelog.d/unreleased/5227.fixed.md +++ b/changelog.d/unreleased/5227.fixed.md @@ -6,6 +6,7 @@ affected: - src/CodeIndex/Models/StatusFreshnessEvaluator.cs - src/CodeIndex/Models/QueryResults.cs - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/GitProcessRunner.cs - src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs - src/CodeIndex/Cli/QueryCommandRunner.Status.cs - src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -13,6 +14,7 @@ affected: - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs - tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs - tests/CodeIndex.Tests/GitHelperTests.cs + - tests/CodeIndex.Tests/GitProcessRunnerTests.cs - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 509d9acb5..ab3541f1e 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1117,7 +1117,7 @@ internal static bool ResolveIgnoreCase( "core.quotePath=false", "status", "--porcelain", - "--untracked-files=all"); + "--untracked-files=normal"); if (output == null) return null; @@ -1202,31 +1202,25 @@ private static string ParsePorcelainPath(string path) string projectRoot, CancellationToken cancellationToken = default) { - var output = TryRunGit( + return TryRunGitMatchingOutputLine( projectRoot, - gitEnvironmentOverrides: null, cancellationToken, + HasWorktreeVisibilityLimitingIndexFlag, "-c", "core.quotePath=false", "ls-files", "-v"); - if (output == null) - return null; - - foreach (var rawLine in output.Split('\n')) - { - var line = rawLine.TrimEnd('\r'); - if (line.Length < 3 || line[1] != ' ') - continue; + } - // `S` is skip-worktree. With `-v`, any lowercase tag means the - // assume-unchanged bit is set (including lowercase `s`). - // `S` は skip-worktree。`-v` の小文字 tag は assume-unchanged を示す。 - if (line[0] == 'S' || char.IsLower(line[0])) - return true; - } + private static bool HasWorktreeVisibilityLimitingIndexFlag(string line) + { + if (line.Length < 3 || line[1] != ' ') + return false; - return false; + // `S` is skip-worktree. With `-v`, any lowercase tag means the + // assume-unchanged bit is set (including lowercase `s`). + // `S` は skip-worktree。`-v` の小文字 tag は assume-unchanged を示す。 + return line[0] == 'S' || char.IsLower(line[0]); } internal static string? TryGetRepositoryRoot( @@ -1277,6 +1271,37 @@ private static bool HasGitMetadataEntry(string projectRoot) private static string? TryRunGit(string projectRoot, CancellationToken cancellationToken, params string[] args) => TryRunGit(projectRoot, gitEnvironmentOverrides: null, cancellationToken, args); + private static bool? TryRunGitMatchingOutputLine( + string projectRoot, + CancellationToken cancellationToken, + Func predicate, + params string[] args) + { + try + { + var psi = TryCreateGitStartInfo(projectRoot); + if (psi == null) + return null; + + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + return GitProcessRunner.RunMatchingStdoutLine( + psi, + GitCommandTimeout, + predicate, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + internal readonly record struct GitCommandResult( int? ExitCode, string? Output, diff --git a/src/CodeIndex/Cli/GitProcessRunner.cs b/src/CodeIndex/Cli/GitProcessRunner.cs index 804a9d31a..e86d347e1 100644 --- a/src/CodeIndex/Cli/GitProcessRunner.cs +++ b/src/CodeIndex/Cli/GitProcessRunner.cs @@ -44,6 +44,18 @@ internal static (int ExitCode, string Output, string Error)? RunCapturingOutput( CancellationToken cancellationToken = default) => RunCapturingResultAsync(psi, timeout, cancellationToken).GetAwaiter().GetResult(); + // Scan stdout line-by-line without retaining the complete path listing. This is for + // Git queries such as `ls-files` where the answer is existential but output can exceed + // the bounded diagnostic capture contract in large repositories. + // `ls-files` のように存在判定だけが必要な Git query を、巨大 repository でも path 一覧を + // 全保持せず stdout の行単位で走査する。 + internal static bool? RunMatchingStdoutLine( + ProcessStartInfo psi, + TimeSpan timeout, + Func predicate, + CancellationToken cancellationToken = default) + => RunMatchingStdoutLineAsync(psi, timeout, predicate, cancellationToken).GetAwaiter().GetResult(); + internal static string FormatDiagnostic(string diagnostic) { var boundedBeforeRedaction = DiagnosticRedactor.BoundDiagnosticText( @@ -160,6 +172,64 @@ void MarkFailure(GitCommandFailureKind kind, string diagnostic) return new CaptureResult(exitCode, output, error, GitCommandFailureKind.None, null); } + private static async Task RunMatchingStdoutLineAsync( + ProcessStartInfo psi, + TimeSpan timeout, + Func predicate, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(predicate); + cancellationToken.ThrowIfCancellationRequested(); + using var process = new Process { StartInfo = psi }; + var stderr = new StringBuilder(); + GitCommandFailureKind failureKind = GitCommandFailureKind.None; + var failureLock = new object(); + + void MarkFailure(GitCommandFailureKind kind, string _) + { + lock (failureLock) + { + if (failureKind != GitCommandFailureKind.None) + return; + failureKind = kind; + } + TryKillProcessTree(process); + } + + try + { + if (!process.Start()) + return null; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException) + { + return null; + } + + var stdoutTask = ReadMatchingOutputLinesAsync(process.StandardOutput, predicate, MarkFailure); + var stderrTask = ReadCapturedStreamAsync(process.StandardError, stderr, "stderr", MarkFailure); + var waitResult = await WaitForGitExitAsync(process, timeout, cancellationToken).ConfigureAwait(false); + var cancelled = waitResult.Cancelled; + if (!waitResult.Exited) + { + MarkFailure( + cancelled ? GitCommandFailureKind.Cancelled : GitCommandFailureKind.TimedOut, + cancelled + ? "git command cancelled." + : $"git command timed out after {FormatDuration(timeout)}."); + _ = await WaitForGitExitAfterKillAsync(process, GitKillWaitTimeout).ConfigureAwait(false); + } + + if (!await WaitForCaptureReadersAsync(stdoutTask, stderrTask).ConfigureAwait(false)) + MarkFailure(GitCommandFailureKind.OutputCaptureIncomplete, "git command output scan did not finish."); + + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); + if (failureKind != GitCommandFailureKind.None || !process.HasExited || process.ExitCode != 0) + return null; + return await stdoutTask.ConfigureAwait(false); + } + private readonly record struct GitExitWaitResult(bool Exited, bool Cancelled); private static async Task WaitForGitExitAsync( @@ -257,6 +327,34 @@ private static async Task ReadCapturedStreamAsync( } } + private static async Task ReadMatchingOutputLinesAsync( + TextReader reader, + Func predicate, + Action markFailure) + { + var matched = false; + try + { + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (!matched && predicate(line)) + matched = true; + } + } + catch (IOException ex) + { + markFailure( + GitCommandFailureKind.CaptureFailed, + $"git command stdout scan failed: {DiagnosticRedactor.ClassifyException(ex)}"); + } + catch (ObjectDisposedException) + { + // Process cleanup closed the stream after another failure path. + } + + return matched; + } + private static bool AppendBoundedCapturedChars( StringBuilder builder, ReadOnlySpan data, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 060e958e4..badb343de 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2077,12 +2077,15 @@ public sealed class StatusHeadFreshness var indexedHeadMatchesLatest = indexedHead != null && string.Equals(indexedHead, latestIndexHead, StringComparison.OrdinalIgnoreCase); var workspaceCheck = status.WorkspaceCheck; + var workspaceFreshness = workspaceCheck is null + ? (StatusFreshnessEvaluation?)null + : StatusFreshnessEvaluator.Evaluate(workspaceCheck, status.WorktreeHeadChanged); return new StatusHeadFreshness { - State = ResolveState(status, workspaceCheck), + State = ResolveState(status, workspaceCheck, workspaceFreshness), Scope = "workspace", - StateReason = ResolveStateReason(status, workspaceCheck), + StateReason = ResolveStateReason(status, workspaceCheck, workspaceFreshness), RuntimeHead = NullIfWhiteSpace(status.GitHead), IndexedHead = indexedHead, IndexedHeadSource = ResolveIndexedHeadSource(status), @@ -2093,7 +2096,11 @@ public sealed class StatusHeadFreshness IndexedHeadTimestamp = indexedHeadMatchesLatest ? status.IndexedHeadTimestamp : null, WorkspaceCheckIndexedHeadCommit = NullIfWhiteSpace(workspaceCheck?.IndexedHeadCommit), WorkspaceCheckWorkspaceHeadCommit = NullIfWhiteSpace(workspaceCheck?.WorkspaceHeadCommit), - WorkspaceMatchesIndex = status.IndexMatchesWorkspace ?? workspaceCheck?.MatchesWorkspace, + WorkspaceMatchesIndex = workspaceCheck is null + ? status.IndexMatchesWorkspace + : workspaceCheck.Checked + ? workspaceFreshness?.State == StatusFreshnessState.Fresh + : null, WorktreeHeadChanged = status.WorktreeHeadChanged, CommitsAheadOfIndexedHead = indexedHeadMatchesLatest ? status.CommitsAheadOfIndexedHead : null, }; @@ -2165,16 +2172,19 @@ status.WorkspaceCheck is not null || status.CommitsAheadOfIndexedHead.HasValue || status.IndexMatchesWorkspace.HasValue; - private static string ResolveState(StatusResult status, IndexFreshnessCheckResult? workspaceCheck) + private static string ResolveState( + StatusResult status, + IndexFreshnessCheckResult? workspaceCheck, + StatusFreshnessEvaluation? workspaceFreshness) { if (workspaceCheck is not null) { - if (!workspaceCheck.Checked) + if (workspaceFreshness?.State == StatusFreshnessState.Unknown) return "check_unavailable"; - if (!workspaceCheck.MatchesWorkspace) - return IsHeadChanged(status, workspaceCheck) - ? "head_changed" - : status.IndexComplete ? "stale" : "stale_and_incomplete"; + if (workspaceFreshness?.State == StatusFreshnessState.HeadChanged) + return "head_changed"; + if (workspaceFreshness?.State != StatusFreshnessState.Fresh) + return status.IndexComplete ? "stale" : "stale_and_incomplete"; return status.IndexComplete ? "fresh" : "fresh_but_incomplete"; } @@ -2185,12 +2195,15 @@ private static string ResolveState(StatusResult status, IndexFreshnessCheckResul return "unchecked"; } - private static string? ResolveStateReason(StatusResult status, IndexFreshnessCheckResult? workspaceCheck) + private static string? ResolveStateReason( + StatusResult status, + IndexFreshnessCheckResult? workspaceCheck, + StatusFreshnessEvaluation? workspaceFreshness) { - if (!status.IndexComplete && workspaceCheck?.Checked == true && workspaceCheck.MatchesWorkspace) + if (!status.IndexComplete && workspaceFreshness?.State == StatusFreshnessState.Fresh) return "index_incomplete"; if (workspaceCheck is not null) - return string.IsNullOrWhiteSpace(workspaceCheck.Reason) ? null : workspaceCheck.Reason; + return string.IsNullOrWhiteSpace(workspaceFreshness?.Reason) ? null : workspaceFreshness?.Reason; if (status.WorktreeHeadChanged == true) return "worktree_head_changed"; if (status.WorktreeHeadChanged == false) @@ -2198,11 +2211,6 @@ private static string ResolveState(StatusResult status, IndexFreshnessCheckResul return null; } - private static bool IsHeadChanged(StatusResult status, IndexFreshnessCheckResult workspaceCheck) => - workspaceCheck.HeadChanged - || status.WorktreeHeadChanged == true - || string.Equals(workspaceCheck.Reason, "head_changed", StringComparison.Ordinal); - private static string ResolveIndexedHeadSource(StatusResult status) { if (!string.IsNullOrWhiteSpace(status.WorkspaceVerifiedHeadSha)) diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 1ed5880de..9590bbd62 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -1531,7 +1531,9 @@ public void TryIsWorktreeDirty_ForcesUntrackedVisibilityDespiteGitConfig_Issue52 RunGit(repoDir, "commit", "--allow-empty", "-m", "initial"); RunGit(repoDir, "config", "status.showUntrackedFiles", "no"); - File.WriteAllText(Path.Combine(repoDir, "untracked.txt"), "indexed outside git\n"); + var untrackedDirectory = Path.Combine(repoDir, "untracked"); + Directory.CreateDirectory(untrackedDirectory); + File.WriteAllText(Path.Combine(untrackedDirectory, "nested.txt"), "indexed outside git\n"); Assert.True(GitHelper.TryIsWorktreeDirty(repoDir)); Assert.True(GitHelper.TryGetWorktreeStatus(repoDir)?.IsDirty); diff --git a/tests/CodeIndex.Tests/GitProcessRunnerTests.cs b/tests/CodeIndex.Tests/GitProcessRunnerTests.cs index ad18732d6..f95931230 100644 --- a/tests/CodeIndex.Tests/GitProcessRunnerTests.cs +++ b/tests/CodeIndex.Tests/GitProcessRunnerTests.cs @@ -49,6 +49,36 @@ public void RunCapturingResult_PreservesExitDiagnosticContract_Issue4179() Assert.DoesNotContain("/Users/example/private", value.Diagnostic!); } + [ExternalProcessFact] + public void RunMatchingStdoutLine_ScansBeyondCaptureLimitWithoutMaterializingOutput_Issue5227() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(tempDir, "streaming-repo"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(tempDir, "streaming-fake-git"); + Directory.CreateDirectory(fakeGitDir); + var fakeGit = WriteFakeGitWithLargeStdoutAndMatch(fakeGitDir); + var psi = new ProcessStartInfo + { + FileName = fakeGit, + WorkingDirectory = repoDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var matched = GitProcessRunner.RunMatchingStdoutLine( + psi, + TimeSpan.FromSeconds(5), + line => line.StartsWith("S ", StringComparison.Ordinal), + CancellationToken.None); + + Assert.True(matched); + } + private static string WriteFakeGitThatFailsWithLongSensitiveStderr(string directory) { var script = Path.Combine(directory, "git"); @@ -56,6 +86,18 @@ private static string WriteFakeGitThatFailsWithLongSensitiveStderr(string direct #!/bin/sh perl -e 'print STDERR "/Users/example/private/repo/.git/config " . ("x" x 2000)' exit 23 +"""); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return script; + } + + private static string WriteFakeGitWithLargeStdoutAndMatch(string directory) + { + var script = Path.Combine(directory, "git-streaming"); + File.WriteAllText(script, """ +#!/bin/sh +perl -e 'for ($i = 0; $i < 20000; $i++) { print "H tracked/path/$i/abcdefghijklmnopqrstuvwxyz0123456789.cs\n"; } print "S hidden.cs\n"' """); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); diff --git a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs index 26583593d..70bbe09fe 100644 --- a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs +++ b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs @@ -70,6 +70,11 @@ public void Evaluate_OrdinaryStatusKeepsConservativeTimestampAndWorktreeBoundari var checkedHeadChanged = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); Assert.Equal(StatusFreshnessState.HeadChanged, checkedHeadChanged.State); Assert.Equal("head_changed", checkedHeadChanged.Reason); + var headFreshness = status.HeadFreshness; + Assert.NotNull(headFreshness); + Assert.Equal("head_changed", headFreshness.State); + Assert.Equal("head_changed", headFreshness.StateReason); + Assert.False(headFreshness.WorkspaceMatchesIndex); status.WorkspaceCheck = null; status.WorktreeHeadChanged = false; From 6655e6c67bd20d1969c2e225e6bfca32fe760db6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 1 Sep 2026 03:06:27 +0900 Subject: [PATCH 6/6] Preserve dirty status freshness parity (#5227) --- DEVELOPER_GUIDE.md | 22 ++++---- README.md | 16 +++--- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5227.fixed.md | 6 ++- .../Cli/QueryCommandRunner.RepairCommands.cs | 12 ++++- .../Cli/QueryCommandRunner.Status.cs | 13 +++-- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 4 +- .../Cli/WorkspaceMetadataEnricher.cs | 52 +++++++++++++------ .../Mcp/McpToolHandlers.Query.Status.cs | 13 +++-- src/CodeIndex/Models/QueryResults.cs | 5 +- .../Models/StatusFreshnessEvaluator.cs | 12 ++++- .../IndexCommandRunnerTests.cs | 43 ++++++++++++++- .../StatusFreshnessEvaluatorTests.cs | 12 +++++ .../WorkspaceMetadataEnricherTests.cs | 35 +++++++++++++ 14 files changed, 199 insertions(+), 50 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index eeb0ee4fa..8b4695ae7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1520,8 +1520,8 @@ Current stable codes and triggers: | Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. | Status freshness summaries are classified by one shared evaluator. An -authoritative `status --check` supplies the file-level result, while status-level -HEAD/branch drift takes precedence. Without that check, +authoritative `status --check` supplies the file-level result, while a dirty +worktree or status-level HEAD/branch drift takes precedence. Without that check, `last_workspace_freshened_at >= latest_modified` can prove a checksum-reused no-op update fresh only when the worktree is clean and the runtime, workspace-verified, and latest-index HEAD SHAs all agree, and Git reports no @@ -1530,9 +1530,10 @@ The Git dirtiness probe explicitly requests all untracked files, overriding a repository-level `status.showUntrackedFiles=no` setting. Missing provenance, hidden index state, and future timestamps are `unknown`; a later modification, dirty worktree, or changed HEAD remains conservative. Status-level HEAD/branch -drift is evaluated before an authoritative file check and is propagated into -checked failures, exit status, and workspace member health, so a same-SHA -detached/branch transition cannot produce contradictory outcomes. Ordinary +repository-level dirtiness and drift are evaluated before an authoritative file +check and are propagated into checked failures, exit status, and workspace member +health, so an already-indexed untracked path or same-SHA detached/branch transition +cannot produce contradictory outcomes. Ordinary `head_freshness=head_current` semantics remain distinct from the authoritative checked `fresh` value. @@ -5705,17 +5706,18 @@ apply 時は `PRAGMA optimize` を実行します。 | newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 | status freshness summary は1つの共有 evaluator で分類します。authoritative な -`status --check` は file-level の結果を提供し、status-level の HEAD / branch drift は -それより優先します。check がない場合、 +`status --check` は file-level の結果を提供し、dirty worktree または status-level の +HEAD / branch drift はそれより優先します。check がない場合、 `last_workspace_freshened_at >= latest_modified` が checksum 再利用 no-op update の freshness を証明できるのは、worktree が clean で、runtime、workspace 検証済み、直近 index の HEAD SHA がすべて一致し、後続変更を隠せる `skip-worktree` / `assume-unchanged` entry が Git index に無い場合だけです。Git の dirtiness probe は未追跡 file を明示的に すべて要求し、repository の `status.showUntrackedFiles=no` 設定を上書きします。provenance 不足、隠れた index state、未来 timestamp は `unknown` とし、後続の変更、dirty worktree、 -HEAD 変更は保守的な判定を維持します。status-level の HEAD / branch drift は authoritative -file check より先に評価して checked failure、終了 status、workspace member health にも -伝播するため、同一 SHA の detached / branch 遷移でも outcome は矛盾しません。通常 status +HEAD 変更は保守的な判定を維持します。repository-level の dirtiness と drift は +authoritative file check より先に評価して checked failure、終了 status、workspace member +health にも伝播するため、index 済み未追跡 path や同一 SHA の detached / branch 遷移でも +outcome は矛盾しません。通常 status の `head_freshness=head_current` は、authoritative check 済みの `fresh` と引き続き区別します。 `vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 diff --git a/README.md b/README.md index a899c4386..0dda29d8d 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,11 @@ clean. Git index flags that can hide worktree changes (`skip-worktree` or dirtiness probe always includes untracked files even when `status.showUntrackedFiles=no`. Missing provenance or a future timestamp also yields `unknown`; an actual workspace change remains `stale`. `status --check` -performs the authoritative workspace comparison, and a status-level HEAD/branch -transition remains conservative even when file checks match. That transition also -fails the checked status and member-health result; CLI, workspace, and MCP status -surfaces therefore share the same classification and check outcome. +performs the authoritative workspace comparison, while a dirty worktree or a +status-level HEAD/branch transition remains conservative even when file checks +match. Those repository-level signals also fail the checked status and +member-health result; CLI, workspace, and MCP status surfaces therefore share the +same classification and check outcome. Persisted JSON subdocuments for `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, and @@ -464,9 +465,10 @@ field group を表に残します。 dirtiness probe は `status.showUntrackedFiles=no` の設定時も未追跡 file を必ず含めます。 provenance が欠けている場合や timestamp が未来の場合も `unknown`、実際の workspace 変更がある場合は引き続き `stale` です。`status --check` は authoritative な workspace -比較を行いますが、file check が一致しても status-level の HEAD / branch 遷移は保守的に -扱います。この遷移は checked status と member-health result も失敗させるため、CLI、 -workspace、MCP の status surface は分類と check outcome の両方を共有します。 +比較を行いますが、file check が一致しても dirty worktree または status-level の HEAD / +branch 遷移は保守的に扱います。これらの repository-level signal は checked status と +member-health result も失敗させるため、CLI、workspace、MCP の status surface は分類と +check outcome の両方を共有します。 `last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、 `last_failed_or_partial_index_run.file_errors` の永続化 JSON subdocument には、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ab11be3d5..f105da7ee 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,7 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack -- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, and `WorkspaceCommandRunnerTests.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 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. +- 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. - Issue #5226 impact-count coverage belongs in `DbReaderIssue5226Tests.cs`, `QueryCommandRunnerIssue5226Tests.cs`, `QueryCommandRunnerImpactTests.cs`, and `McpServerIssue5226Tests.cs`. Keep zero, one, above-default, multi-hop, include/exclude-filter, incomplete-graph, dedicated safety-cap, heuristic-fallback, cross-language SQL-readiness, and ordinary row-limited cases together. Human, JSON, compact, and MCP count modes must ignore the presentation limit while preserving authoritative/lower-bound metadata and omitting count-only result rows; capped human output must retain numeric stdout and warn with the reason on stderr. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. @@ -1174,7 +1174,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック -- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag、および 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 します。 +- 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 では実行してはいけません。 - Issue #5226 の impact count coverage は `DbReaderIssue5226Tests.cs`、`QueryCommandRunnerIssue5226Tests.cs`、`QueryCommandRunnerImpactTests.cs`、`McpServerIssue5226Tests.cs` が担当します。0件、1件、既定上限超過、multi-hop、include / exclude filter、不完全 graph、専用 safety cap、heuristic fallback、cross-language の SQL readiness、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。cap 到達時の human 出力は stdout を数値のみのまま保ち、stderr に理由付き warning を出します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md index 2bb8dbd3a..b9c7f6c82 100644 --- a/changelog.d/unreleased/5227.fixed.md +++ b/changelog.d/unreleased/5227.fixed.md @@ -9,6 +9,7 @@ affected: - src/CodeIndex/Cli/GitProcessRunner.cs - src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs - src/CodeIndex/Cli/WorkspaceCommandRunner.cs - src/CodeIndex/Database/DbReader.WorkspaceHealth.cs - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -18,6 +19,7 @@ affected: - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs - README.md - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -25,8 +27,8 @@ affected: ## English -- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier and check outcome. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, while untracked-file detection overrides `status.showUntrackedFiles=no` and same-commit branch drift fails checked status and workspace health. This closes the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. +- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier and check outcome. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, while untracked-file detection overrides `status.showUntrackedFiles=no`; dirty worktrees and same-commit branch drift fail checked status and workspace health even when the content check matches. This closes the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. ## 日本語 -- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier と check outcome を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立する一方、未追跡 file の検出は `status.showUntrackedFiles=no` を上書きし、同一 commit の branch drift は checked status と workspace health を失敗させます。これにより #3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 +- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier と check outcome を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立する一方、未追跡 file の検出は `status.showUntrackedFiles=no` を上書きし、dirty worktree と同一 commit の branch drift は content check が一致しても checked status と workspace health を失敗させます。これにより #3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs b/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs index e640bff1c..dfdaf4c7c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs @@ -15,7 +15,11 @@ private static string BuildFoldBackfillCommand(string dbPath, bool dbPathExplici private static string BuildCSharpCanonicalNameRepairCommand(DbReader reader, QueryCommandOptions options) { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + evaluateOrdinaryFreshness: false); return BuildCSharpCanonicalNameRepairCommand(status.ProjectRoot, options.DbPath, options.DbPathExplicit); } @@ -25,7 +29,11 @@ private static string BuildCSharpCanonicalNameRepairCommand(string? projectRoot, private static string BuildSqlGraphContractRepairCommand(DbReader reader, QueryCommandOptions options) { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + evaluateOrdinaryFreshness: false); return BuildSqlGraphContractRepairCommand(status.ProjectRoot, options.DbPath, options.DbPathExplicit); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 22f947d3f..b63a7b5b3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -112,7 +112,12 @@ public static int RunStatus( } var status = reader.GetStatus(includeDatabaseSizeAttribution: options.Json); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit, cancellationToken); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + cancellationToken, + evaluateOrdinaryFreshness: !options.CheckWorkspace); status.DataDir = options.DataDir; status.DataDirSource = options.DataDirSource; status.DataDirMode = DataDirectorySecurity.GetUnixModeString(GetDataDirectoryPath(options.DbPath)); @@ -139,7 +144,8 @@ public static int RunStatus( status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked ? StatusFreshnessEvaluator.Evaluate( status.WorkspaceCheck, - status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh + status.WorktreeHeadChanged, + status.GitIsDirty).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = (long)Math.Round(staleAfter.Value.TotalSeconds, MidpointRounding.AwayFromZero); status.QueryContext = new StatusQueryContext @@ -1011,7 +1017,8 @@ private static IReadOnlyList BuildStatusCheckFailures(Status var check = status.WorkspaceCheck; var freshness = StatusFreshnessEvaluator.Evaluate( check, - status.WorktreeHeadChanged); + status.WorktreeHeadChanged, + status.GitIsDirty); if (freshness.State != StatusFreshnessState.Fresh) { failures.Add(new StatusCheckFailure( diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 56b085f05..722e1b47a 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -440,9 +440,11 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( snapshot.IndexedHeadCommit, snapshot.IndexedHeadCommitBranch, snapshot.IndexedHeadCommitBranchStampPresent); + var gitIsDirty = GitHelper.TryIsWorktreeDirty(projectRoot, cancellationToken); var freshnessEvaluation = StatusFreshnessEvaluator.Evaluate( freshness, - worktreeHeadChanged); + worktreeHeadChanged, + gitIsDirty); var status = "ready"; var reason = "ready"; if (freshnessEvaluation.State == StatusFreshnessState.Unknown) diff --git a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs index 1b59e92a0..d15aeb2e9 100644 --- a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs +++ b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs @@ -15,7 +15,8 @@ public static void Enrich( StatusResult status, string dbPath, bool dbPathExplicit = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool evaluateOrdinaryFreshness = true) { if (status.HeadMetadataSnapshotCaptured) { @@ -29,10 +30,6 @@ public static void Enrich( status.ProjectRoot = runtime.ProjectRoot; status.GitHead = runtime.RuntimeHead; status.GitIsDirty = runtime.IsDirty; - status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( - status, - runtime.ProjectRoot, - cancellationToken); status.WorktreeHeadChanged = ResolveHeadChanged( runtime.RuntimeHead, runtime.RuntimeBranch, @@ -43,6 +40,11 @@ public static void Enrich( status.IndexedHeadCommit, status.IndexedHeadCommitBranchSnapshot, status.IndexedHeadCommitBranchStampPresentSnapshot); + status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( + status, + runtime.ProjectRoot, + evaluateOrdinaryFreshness, + cancellationToken); if (runtime.ProjectRoot != null && !string.IsNullOrWhiteSpace(status.IndexedHeadSha)) { status.CommitsAheadOfIndexedHead = GitHelper.TryCountCommitsAhead( @@ -57,13 +59,14 @@ public static void Enrich( status.ProjectRoot = metadata.ProjectRoot; status.GitHead = metadata.RuntimeHead; status.GitIsDirty = metadata.IsDirty; + status.IndexedHeadCommit = metadata.LegacyIndexedHead; + status.WorkspaceVerifiedHeadSha = metadata.WorkspaceVerifiedHead; + status.WorktreeHeadChanged = metadata.HeadChanged; status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( status, metadata.ProjectRoot, + evaluateOrdinaryFreshness, cancellationToken); - status.IndexedHeadCommit = metadata.LegacyIndexedHead; - status.WorkspaceVerifiedHeadSha = metadata.WorkspaceVerifiedHead; - status.WorktreeHeadChanged = metadata.HeadChanged; // Keep commit-drift diagnostics tied to the latest-write SHA. Whole-workspace // freshness uses the separate verification stamp above, so these two provenance // signals remain explicit instead of silently substituting for each other. @@ -74,23 +77,42 @@ public static void Enrich( private static bool? ResolveGitIndexVisibility( StatusResult status, string? projectRoot, + bool evaluateOrdinaryFreshness, CancellationToken cancellationToken) { - if (projectRoot == null + if (!ShouldProbeGitIndexVisibility(status, projectRoot, evaluateOrdinaryFreshness)) + return null; + + // Keep ordinary status cheap outside the checksum-reused no-op case. Only the + // fallback proof needs to rule out index flags that can mask later changes. + // checksum 再利用 no-op の fallback 証拠を使う場合だけ index flag を確認し、 + // それ以外の通常 status には追加の Git 列挙を行わない。 + return GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(projectRoot!, cancellationToken); + } + + internal static bool ShouldProbeGitIndexVisibility( + StatusResult status, + string? projectRoot, + bool evaluateOrdinaryFreshness) + { + if (!evaluateOrdinaryFreshness + || projectRoot == null + || status.GitIsDirty != false + || status.WorktreeHeadChanged != false || !status.IndexedAt.HasValue || !status.LatestModified.HasValue || !status.LastWorkspaceFreshenedAt.HasValue || status.IndexedAt.Value >= status.LatestModified.Value || status.LastWorkspaceFreshenedAt.Value < status.LatestModified.Value) { - return null; + return false; } - // Keep ordinary status cheap outside the checksum-reused no-op case. Only the - // fallback proof needs to rule out index flags that can mask later changes. - // checksum 再利用 no-op の fallback 証拠を使う場合だけ index flag を確認し、 - // それ以外の通常 status には追加の Git 列挙を行わない。 - return GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(projectRoot, cancellationToken); + return !string.IsNullOrWhiteSpace(status.GitHead) + && !string.IsNullOrWhiteSpace(status.WorkspaceVerifiedHeadSha) + && !string.IsNullOrWhiteSpace(status.IndexedHeadSha) + && string.Equals(status.GitHead, status.WorkspaceVerifiedHeadSha, StringComparison.OrdinalIgnoreCase) + && string.Equals(status.WorkspaceVerifiedHeadSha, status.IndexedHeadSha, StringComparison.OrdinalIgnoreCase); } public static void Enrich( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 67633646d..7b9dd050a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -46,7 +46,12 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) || projectionFields.Contains("database_size_attribution", StringComparer.Ordinal)); var status = reader.GetStatus(includeDatabaseSizeAttribution); QueryCommandRunner.ApplyStatusSymbolKindLimits(status, reader.GetSymbolKindCounts()); - WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit, requestToken); + WorkspaceMetadataEnricher.Enrich( + status, + _dbPath, + _dbPathExplicit, + requestToken, + evaluateOrdinaryFreshness: !checkWorkspace); status.DbFileMode = DbContext.GetUnixFileModeString( _dbPath, status.DatabasePermissionPolicy, @@ -70,7 +75,8 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked ? StatusFreshnessEvaluator.Evaluate( status.WorkspaceCheck, - status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh + status.WorktreeHeadChanged, + status.GitIsDirty).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = staleAfterSeconds; if (status.IndexedAt.HasValue) @@ -349,7 +355,8 @@ private static IReadOnlyList BuildMcpStatusCheckFailures( var check = status.WorkspaceCheck; var freshness = StatusFreshnessEvaluator.Evaluate( check, - status.WorktreeHeadChanged); + status.WorktreeHeadChanged, + status.GitIsDirty); if (freshness.State != StatusFreshnessState.Fresh) { failures.Add(new McpStatusCheckFailure( diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index badb343de..e61708dbc 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2079,7 +2079,10 @@ public sealed class StatusHeadFreshness var workspaceCheck = status.WorkspaceCheck; var workspaceFreshness = workspaceCheck is null ? (StatusFreshnessEvaluation?)null - : StatusFreshnessEvaluator.Evaluate(workspaceCheck, status.WorktreeHeadChanged); + : StatusFreshnessEvaluator.Evaluate( + workspaceCheck, + status.WorktreeHeadChanged, + status.GitIsDirty); return new StatusHeadFreshness { diff --git a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs index b988dec89..0af855854 100644 --- a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs +++ b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs @@ -32,7 +32,7 @@ internal static class StatusFreshnessEvaluator public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime utcNow) { if (status.WorkspaceCheck is not null) - return Evaluate(status.WorkspaceCheck, status.WorktreeHeadChanged); + return Evaluate(status.WorkspaceCheck, status.WorktreeHeadChanged, status.GitIsDirty); if (status.WorktreeHeadChanged == true) return new(StatusFreshnessState.HeadChanged, "head_changed"); @@ -72,14 +72,22 @@ public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime u } public static StatusFreshnessEvaluation Evaluate(IndexFreshnessCheckResult check) - => Evaluate(check, worktreeHeadChanged: null); + => Evaluate(check, worktreeHeadChanged: null, gitIsDirty: null); public static StatusFreshnessEvaluation Evaluate( IndexFreshnessCheckResult check, bool? worktreeHeadChanged) + => Evaluate(check, worktreeHeadChanged, gitIsDirty: null); + + public static StatusFreshnessEvaluation Evaluate( + IndexFreshnessCheckResult check, + bool? worktreeHeadChanged, + bool? gitIsDirty) { if (worktreeHeadChanged == true) return new(StatusFreshnessState.HeadChanged, "head_changed"); + if (gitIsDirty == true) + return new(StatusFreshnessState.Stale, "worktree_dirty"); if (!check.Checked) return new(StatusFreshnessState.Unknown, "freshness_check_unavailable"); if (check.MatchesWorkspace) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index ab92af3c8..60f6d7322 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6582,6 +6582,45 @@ public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgre CommandExitCodes.Success, RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]).ExitCode); + using (var indexedUntrackedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var indexedUntrackedReader = new DbReader(indexedUntrackedDb)) + { + var indexedUntracked = indexedUntrackedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(indexedUntracked, dbPath, dbPathExplicit: true); + Assert.True(indexedUntracked.GitIsDirty); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(indexedUntracked, clock.GetUtcNow().UtcDateTime)); + + indexedUntracked.WorkspaceCheck = IndexFreshnessChecker.Check( + indexedUntrackedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.True(indexedUntracked.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(indexedUntracked, clock.GetUtcNow().UtcDateTime)); + } + + var (indexedUntrackedStatusExitCode, indexedUntrackedStatusJson) = RunStatusAndCaptureJson( + ["--db", dbPath, "--check", "--json"]); + Assert.Equal(1, indexedUntrackedStatusExitCode); + Assert.True(indexedUntrackedStatusJson.GetProperty("workspace_check").GetProperty("matches_workspace").GetBoolean()); + Assert.False(indexedUntrackedStatusJson.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Contains("index stale", indexedUntrackedStatusJson.GetProperty("summary").GetString()); + + var indexedUntrackedOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var indexedUntrackedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index stale", + indexedUntrackedOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + var indexedUntrackedCheckedMcpStatus = indexedUntrackedCheckedMcpResponse["result"]!["structuredContent"]!; + Assert.Contains("index stale", indexedUntrackedCheckedMcpStatus["summary"]!.GetValue()); + Assert.False(indexedUntrackedCheckedMcpStatus["index_matches_workspace"]!.GetValue()); + Assert.True(indexedUntrackedCheckedMcpStatus["workspace_check"]!["matches_workspace"]!.GetValue()); + File.WriteAllText(untrackedPath, "print('untracked v2')\n"); File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(8).UtcDateTime); clock.Advance(TimeSpan.FromMinutes(2)); @@ -6607,9 +6646,9 @@ public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgre } var untrackedOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + """{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; var untrackedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + """{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; Assert.Contains( "index stale", untrackedOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); diff --git a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs index 70bbe09fe..b8ad81e1e 100644 --- a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs +++ b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs @@ -82,6 +82,18 @@ public void Evaluate_OrdinaryStatusKeepsConservativeTimestampAndWorktreeBoundari var dirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); Assert.Equal(StatusFreshnessState.Stale, dirty.State); Assert.Equal("worktree_dirty", dirty.Reason); + + status.WorkspaceCheck = new IndexFreshnessCheckResult + { + Checked = true, + MatchesWorkspace = true, + Reason = "matched", + }; + var checkedDirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, checkedDirty.State); + Assert.Equal("worktree_dirty", checkedDirty.Reason); + Assert.Equal("stale", status.HeadFreshness?.State); + Assert.False(status.HeadFreshness?.WorkspaceMatchesIndex); } [Theory] diff --git a/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs b/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs index a7ecb2d6f..2201d5531 100644 --- a/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs @@ -10,6 +10,41 @@ namespace CodeIndex.Tests; [Collection("SQLite pool sensitive")] public class WorkspaceMetadataEnricherTests { + [Fact] + public void ShouldProbeGitIndexVisibility_RequiresUsableOrdinaryFreshnessContext_Issue5227() + { + var indexedAt = new DateTime(2030, 1, 2, 3, 0, 0, DateTimeKind.Utc); + var status = new StatusResult + { + IndexedAt = indexedAt, + LatestModified = indexedAt.AddMinutes(1), + LastWorkspaceFreshenedAt = indexedAt.AddMinutes(2), + GitHead = "0123456789abcdef", + GitIsDirty = false, + WorktreeHeadChanged = false, + WorkspaceVerifiedHeadSha = "0123456789abcdef", + IndexedHeadSha = "0123456789abcdef", + }; + + Assert.True(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility( + status, + "/repo", + evaluateOrdinaryFreshness: true)); + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility( + status, + "/repo", + evaluateOrdinaryFreshness: false)); + + status.GitIsDirty = true; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + status.GitIsDirty = false; + status.WorktreeHeadChanged = true; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + status.WorktreeHeadChanged = false; + status.WorkspaceVerifiedHeadSha = "fedcba9876543210"; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + } + [Fact] public void Enrich_ResultTypes_PopulateProjectRootHeadAndDirty() {