diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 82b9399194..db4603545a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -33,7 +33,7 @@ Development contracts: |---|---| | Formatting and warnings | CI enforces repository formatting with `.editorconfig` and treats compiler warnings as errors through `Directory.Build.props`, so local changes should pass the format check before opening a PR. Existing trim-analysis warnings are explicitly listed in `WarningsNotAsErrors` until they are fixed without blocking ordinary compiler-warning enforcement, and ILLink keeps reporting trim warnings without failing trimmed publish smoke tests. | | CLI help | `cdidx --help` stays brief, `cdidx --help-all` prints the full command/flag/example reference, `cdidx --help-flags` prints only shared flag tables, and `cdidx --help` prints one command's usage line. Keep new commands visible in the brief summary only when they are a primary user workflow; every command must remain present in the full help and command-specific usage table. | -| `index --dry-run` mutation estimates | Dry-run remains strictly non-mutating: it does not create or lock the database and does not alter sources or index artifacts. It uses read-only stat/checksum data together with the production cap-issue, symbol-filter, extractor-version, graph-contract, forced extractor/config refresh, hotspot-marker trust, and C# workspace reuse gates to project unchanged skips; projects updates, content-policy skips, deletes, purges, and symbol/reference cap hits; and performs the normal chunk/symbol/reference/content-diagnostic extraction for at most 100 planned update files. `estimated_table_mutations` counts projected delete-and-insert row operations, not final table cardinality. Its nullable values must stay synchronized with `estimated_table_mutation_details`: `source` identifies filesystem planning or parse-only plus index-snapshot input, `confidence` is `exact`, `estimate`, or `unknown`, and stable `unknown_reasons` cover candidate truncation, parse-estimate truncation/failure, C# workspace expansion, unreadable index snapshots, and partial-index table absence. A numeric zero is reserved for measured no work and remains qualified by its confidence. Parse-only estimates intentionally omit post-extraction hook mutations and cross-file C# workspace augmentation, so child-table values remain estimates; when a changed C# source proves that production must expand the refresh across otherwise reusable C# files, those child-table metrics become explicitly unknown instead of retaining incomplete parse totals. | +| `index --dry-run` mutation estimates | Dry-run remains strictly non-mutating: it does not create or lock the database and does not alter sources or index artifacts. It uses read-only stat/checksum data together with the production cap-issue, symbol-filter, extractor-version, graph-contract, forced extractor/config refresh, hotspot-marker trust, and C# workspace reuse gates to project unchanged skips; projects updates, content-policy skips, deletes, purges, and symbol/reference cap hits; and performs the normal chunk/symbol/reference/content-diagnostic extraction for at most 100 planned update files. Scoped `--files`, `--commits`, and `--changed-between` previews run a read-only C# preflight that reuses production static-interface and qualified member-read detection and expands candidate paths before counts, limits, and samples are finalized. `projection_authoritative`, `projection_unavailable_reasons`, and the C# workspace expansion status/reason distinguish exact projections from lower bounds when a candidate cap, scan failure, snapshot gap, or unavailable preflight prevents safe expansion. `estimated_table_mutations` counts projected delete-and-insert row operations, not final table cardinality. Its nullable values must stay synchronized with `estimated_table_mutation_details`: `source` identifies filesystem planning or parse-only plus index-snapshot input, `confidence` is `exact`, `estimate`, or `unknown`, and stable `unknown_reasons` cover candidate truncation, parse-estimate truncation/failure, C# workspace expansion, unreadable index snapshots, and partial-index table absence. A numeric zero is reserved for measured no work and remains qualified by its confidence. Parse-only estimates intentionally omit post-extraction hook mutations and post-preflight cross-file child-row materialization, so child-table metrics become explicitly unknown when C# workspace expansion applies instead of retaining incomplete parse totals. | | `cdidx validate` | This is the user-facing integrity scan for indexed content issues such as replacement characters, BOMs, NUL bytes, mixed line endings, UTF-16 BOMs, and likely non-UTF8 content. Keep its CLI usage, README entry, and help summary in sync when adding validation issue kinds or filters. | | `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid with `--json --env-inventory=full` or `--integrations --json`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | | Exception diagnostics | User-facing CLI, JSON, MCP, file-issue, and local diagnostic output must not echo raw `ex.Message` directly. Route exception prose through `CommandErrorWriter.FormatSanitizedExceptionMessage`, `DiagnosticSanitizer.ForMessage`, or an existing bounded `DiagnosticRedactor` helper, and use stable error codes/categories when the message is not needed for recovery. Intentional broad catches should match the `risky-code/broad-exception-catch` taxonomy and normalize to bounded diagnostics, private best-effort suppression, or a documented fallback. | @@ -4283,7 +4283,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま |---|---| | formatting と warning | CI は `.editorconfig` による repository formatting を強制し、`Directory.Build.props` により compiler warning を error として扱います。ローカル変更は PR 前に formatting check を通してください。既存の trim 解析警告は、通常の警告エラー化を止めずに修正を進められるよう `WarningsNotAsErrors` に明示列挙されています。ILLink は trimmed publish の smoke test を失敗させずに trim warning を報告し続けます。 | | CLI help | `cdidx --help` は短い概要、`cdidx --help-all` は全コマンド・flag・例の一覧、`cdidx --help-flags` は共有 flag table のみ、`cdidx --help` は 1 コマンドの usage line を出します。新しいコマンドは主要な user workflow である場合だけ簡易概要に載せ、full help とコマンド固有の usage table には必ず載せてください。 | -| `index --dry-run` の mutation 推定 | dry-run は厳密に非変更のままにし、database の作成・lock や source / index artifact の変更を行いません。read-only な stat / checksum と production の cap issue、symbol filter、extractor version、graph contract、extractor / config の強制 refresh、hotspot marker の trust、C# workspace の再利用条件を組み合わせて未変更 skip を判定し、update、内容に対する policy skip、delete、purge、symbol / reference 上限到達を予測し、update 予定 file のうち最大100件に通常の chunk / symbol / reference / content diagnostic 抽出を適用します。`estimated_table_mutations` は最終 table row 数ではなく、delete と insert の予測 row operation 数です。nullable な値は `estimated_table_mutation_details` と常に同期させます。`source` は filesystem plan または parse-only と index snapshot の入力元、`confidence` は `exact` / `estimate` / `unknown`、安定した `unknown_reasons` は candidate の切り詰め、parse 推定の切り詰め・失敗、C# workspace の展開、index snapshot の読み取り不能、partial index の table 不足を表します。数値のゼロは、処理がないと計測できた場合だけに使い、その信頼度も併記します。parse-only 推定では extraction 後の hook mutation と C# の cross-file workspace augmentation を意図的に省くため、child table の値は estimate のままです。変更された C# source によって production が本来再利用可能な C# file にも refresh を展開すると判明した場合は、不完全な parse 合計を残さず、それらの child-table metric を明示的な unknown にします。 | +| `index --dry-run` の mutation 推定 | dry-run は厳密に非変更のままにし、database の作成・lock や source / index artifact の変更を行いません。read-only な stat / checksum と production の cap issue、symbol filter、extractor version、graph contract、extractor / config の強制 refresh、hotspot marker の trust、C# workspace の再利用条件を組み合わせて未変更 skip を判定し、update、内容に対する policy skip、delete、purge、symbol / reference 上限到達を予測し、update 予定 file のうち最大100件に通常の chunk / symbol / reference / content diagnostic 抽出を適用します。scoped な `--files`、`--commits`、`--changed-between` preview は read-only C# preflight を実行し、production と同じ static-interface / 修飾 member-read 検出を再利用して、件数・上限・sample を確定する前に candidate path を展開します。`projection_authoritative`、`projection_unavailable_reasons`、C# workspace 展開の status / reason は、candidate cap、scan failure、snapshot 不足、利用不能な preflight によって安全な展開ができない場合の lower bound と正確な projection を区別します。`estimated_table_mutations` は最終 table row 数ではなく、delete と insert の予測 row operation 数です。nullable な値は `estimated_table_mutation_details` と常に同期させます。`source` は filesystem plan または parse-only と index snapshot の入力元、`confidence` は `exact` / `estimate` / `unknown`、安定した `unknown_reasons` は candidate の切り詰め、parse 推定の切り詰め・失敗、C# workspace の展開、index snapshot の読み取り不能、partial index の table 不足を表します。数値のゼロは、処理がないと計測できた場合だけに使い、その信頼度も併記します。parse-only 推定では extraction 後の hook mutation と preflight 後の cross-file child-row materialization を意図的に省くため、C# workspace 展開が適用された場合は不完全な parse 合計を残さず child-table metric を明示的な unknown にします。 | | `cdidx validate` | replacement character、BOM、NUL byte、混在改行、UTF-16 BOM、非 UTF-8 らしい内容など、indexed content の問題を user-facing に検査する integrity scan です。validation issue の種別や filter を追加する場合は、CLI usage、README entry、help summary を同期してください。 | | `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` または `--integrations --json` と組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | | 例外診断 | user-facing な CLI / JSON / MCP / file issue / local diagnostic output では raw `ex.Message` を直接 echo しないでください。例外の prose は `CommandErrorWriter.FormatSanitizedExceptionMessage`、`DiagnosticSanitizer.ForMessage`、または既存の bounded な `DiagnosticRedactor` helper を通し、回復に message が不要な場合は安定した error code/category を使ってください。意図的に残す broad catch は `risky-code/broad-exception-catch` taxonomy に沿い、bounded diagnostic、private な best-effort suppression、または documented fallback に正規化してください。 | diff --git a/README.md b/README.md index 930be53b90..f039ae2f6e 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,15 @@ table-mutation estimates without changing the source tree or index. See the [User Guide indexing workflow](USER_GUIDE.md#index-a-project) for usage and the [Developer Guide](DEVELOPER_GUIDE.md#build--test) for implementation limits. +Scoped `--files`, `--commits`, and `--changed-between` previews include the +same read-only C# workspace expansion that execution requires for static +interface contracts and qualified member-read refreshes. JSON reports +`projection_authoritative`, `projection_unavailable_reasons`, and +`csharp_workspace_expansion_status` / `csharp_workspace_expansion_reason`. +Expanded paths count toward candidate and sample limits. If a cap, scan error, +or unavailable preflight prevents an exact projection, `totals_lower_bound` is +true and the stable reason explains why the preview is non-authoritative. + ## Status JSON Contract `cdidx status --json` exposes trust, freshness, compatibility, and remediation @@ -403,6 +412,14 @@ file action と上限付き table mutation estimate を preview します。使 [プロジェクトをインデックス](USER_GUIDE.md#プロジェクトをインデックス)、実装上の制限は [開発者ガイド](DEVELOPER_GUIDE.md#ビルドテスト)を参照してください。 +`--files`、`--commits`、`--changed-between` の scoped preview では、static +interface contract と修飾 member-read refresh のために実行時に必要となるものと同じ +read-only C# workspace 展開を反映します。JSON は `projection_authoritative`、 +`projection_unavailable_reasons`、`csharp_workspace_expansion_status` / +`csharp_workspace_expansion_reason` を返します。展開された path も candidate / sample +上限に含まれます。cap、scan error、利用不能な preflight によって正確な予測を作れない場合は +`totals_lower_bound` が true となり、安定した reason が非 authoritative である理由を示します。 + ## Status JSON 契約 `cdidx status --json` は script、MCP client、release check 向けに trust、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 88dcaf2013..ffd1e31f5f 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 #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. - Issue #5194 shell-completion inventory coverage belongs in `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs`. Keep exact contextual inventories for lsp, every workspace verb, config show, diff, import, archive export, and ctags export synchronized with authoritative help and safe parser probes. Preserve positive and negative nested cases, value-taking versus boolean metadata, context-specific short aliases, mutually exclusive diff modes, exact schema-to-renderer sets for Bash/zsh/fish/PowerShell, and the available-shell generated-script execution contract. @@ -1171,6 +1172,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- 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 からの除外を維持してください。 - Issue #5194 の shell completion inventory coverage は `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` が担当します。lsp、workspace の全 verb、config show、diff、import、archive export、ctags export の正確な context 別 inventory を、authoritative help と安全な parser probe に同期してください。nested context の positive / negative case、value-taking と boolean の metadata、context 固有の short alias、diff の排他的 mode、Bash / zsh / fish / PowerShell における schema-to-renderer の完全一致、および利用可能な shell で生成 script を実行する契約を維持します。 diff --git a/changelog.d/unreleased/5225.fixed.md b/changelog.d/unreleased/5225.fixed.md new file mode 100644 index 0000000000..d845199c1b --- /dev/null +++ b/changelog.d/unreleased/5225.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 5225 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Database/DbWriter.CSharpContracts.cs + - tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Scoped C# dry-runs now preview the complete execution write set (#5225)** — `--files`, `--commits`, and `--changed-between` previews apply the same read-only static-interface and member-read workspace expansion as execution, include expanded paths in counts and limits, and report stable non-authoritative reasons when an exact projection is unavailable. + +## 日本語 + +- **scoped C# dry-run が実行時の完全な write set を preview するようになりました (#5225)** — `--files`、`--commits`、`--changed-between` の preview は実行時と同じ read-only static-interface / member-read workspace 展開を適用し、展開 path を件数と上限に含め、正確な予測を作れない場合は安定した非 authoritative reason を返します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 5cdfa195d9..545bc31da4 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -120,6 +120,10 @@ private static int RunDryRun( var projectedCSharpSkips = new List(); var csharpWorkspaceContractDetected = false; var csharpWorkspaceEstimateUnavailable = false; + var csharpWorkspaceEvidenceUnavailable = false; + var csharpTargetAffected = false; + var csharpWorkspaceExpansionStatus = "not_required"; + string? csharpWorkspaceExpansionReason = null; var unsupportedTotal = 0; var unknownExtensionTotal = 0; var unknownExtensionPaths = new List(); @@ -196,6 +200,173 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) return exitCode; } + var dryCandidateList = dryCandidates.ToList(); + var dryCandidateSet = dryCandidateList.ToHashSet(StringComparer.Ordinal); + var dryDeleteCandidateList = dryDeleteCandidates.ToList(); + var expandedCSharpCandidatePaths = new HashSet(StringComparer.Ordinal); + var evaluatedProjectedCSharpPurgePaths = new HashSet(StringComparer.Ordinal); + var hadIndexedCSharpFiles = dbSnapshot.Files.Values.Any( + static rows => rows.Language == "csharp"); + + void MarkCSharpWorkspaceExpansionUnavailable(string reason) + { + csharpWorkspaceEstimateUnavailable = true; + csharpWorkspaceExpansionStatus = "unavailable"; + csharpWorkspaceExpansionReason = reason; + mutationEstimates.MarkAllUnknown(reason); + } + + bool TryExpandDryRunCSharpWorkspace(string reason) + { + if (authoritativeFullScan + || csharpWorkspaceExpansionStatus == "applied" + || csharpWorkspaceExpansionStatus == "unavailable") + { + return true; + } + + if (dryIndexingTargets != null) + { + MarkCSharpWorkspaceExpansionUnavailable( + "csharp_workspace_target_capture_unavailable"); + return true; + } + + try + { + DryRunCSharpExpansionScanStartingForTesting?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + var expandedScan = dryIndexer + .ScanFilesDetailedWithDirectoryListingSnapshots( + cancellationToken: cancellationToken) + .ScanResult; + RecordDryRunScanErrors(expandedScan.Errors); + if (expandedScan.Errors.Any(static error => error.IsFatal)) + { + MarkCSharpWorkspaceExpansionUnavailable( + "csharp_workspace_scan_incomplete"); + return true; + } + + foreach (var path in expandedScan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (expandedScan.FileLanguages.TryGetValue( + path, + out var language) + && language == "csharp" + && dryCandidateSet.Add(path)) + { + dryCandidateList.Add(path); + expandedCSharpCandidatePaths.Add(path); + } + } + + csharpWorkspaceExpansionStatus = "applied"; + csharpWorkspaceExpansionReason = reason; + return true; + } + catch (OperationCanceledException) when ( + cancellationToken.IsCancellationRequested) + { + return false; + } + } + + bool TryApplyPersistedCSharpExpansionEvidence(string indexPath) + { + csharpTargetAffected = true; + if (hadIndexedCSharpFiles + && dbSnapshot.CSharpStaticInterfaceSourceEvidence != false) + { + return TryExpandDryRunCSharpWorkspace( + "persisted_csharp_contract_evidence"); + } + + if (dbSnapshot.Files.TryGetValue(indexPath, out var existing) + && existing.HasCSharpMemberReadTarget) + { + return TryExpandDryRunCSharpWorkspace( + "persisted_csharp_member_read_target"); + } + + if (hadIndexedCSharpFiles + && !dbSnapshot.CSharpWorkspaceEvidenceAvailable) + { + csharpWorkspaceEvidenceUnavailable = true; + } + + return true; + } + + bool TryApplyProjectedCSharpPurgeEvidence() + { + foreach (var projectedPurgePath in projectedPurgePaths) + { + if (!evaluatedProjectedCSharpPurgePaths.Add(projectedPurgePath) + || !dbSnapshot.Files.TryGetValue( + projectedPurgePath, + out var purgedCandidate) + || purgedCandidate.Language != "csharp") + { + continue; + } + + if (!TryApplyPersistedCSharpExpansionEvidence(projectedPurgePath)) + return false; + } + + return true; + } + + foreach (var relativePath in dryDeleteCandidateList) + { + var dbRelativePath = FileIndexer.NormalizeIndexPath(relativePath); + if (dbSnapshot.Files.TryGetValue( + dbRelativePath, + out var deletedCandidate) + && deletedCandidate.Language == "csharp" + && !TryApplyPersistedCSharpExpansionEvidence(dbRelativePath)) + { + return WriteDryRunInterrupted(options, jsonOptions); + } + } + + if (!authoritativeFullScan + && options.ChangedBetweenSpecified + && dbSnapshot.CSharpStaticInterfaceSourceEvidence != false) + { + try + { + var skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths( + projectPath, + cancellationToken); + foreach (var (relativePath, existing) in dbSnapshot.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (existing.Language != "csharp" + || skipWorktreePaths?.Contains(relativePath) == true) + { + continue; + } + + var absolutePath = Path.Combine( + projectPath, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(LongPath.EnsureWindowsPrefix(absolutePath))) + projectedPurgePaths.Add(relativePath); + } + } + catch (OperationCanceledException) when ( + cancellationToken.IsCancellationRequested) + { + return WriteDryRunInterrupted(options, jsonOptions); + } + + if (!TryApplyProjectedCSharpPurgeEvidence()) + return WriteDryRunInterrupted(options, jsonOptions); + } + var currentHotspotFamilyMarkerFingerprints = authoritativeFullScan ? dryScanMetadata.ProjectMarkerFingerprints : GetHotspotFamilyMarkerFingerprints(dryIndexer, cancellationToken); @@ -221,8 +392,11 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) } var dryIndexingTargetIndex = 0; - foreach (var f in dryCandidates) + for (var dryCandidateIndex = 0; + dryCandidateIndex < dryCandidateList.Count; + dryCandidateIndex++) { + var f = dryCandidateList[dryCandidateIndex]; if (candidatePathsProcessed >= dryRunPathLimit) { candidatePathsTruncated = true; @@ -250,6 +424,15 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) FileIndexer.GetRelativePathFromDirectory(projectPath, f)); var dbRelativePath = capturedTarget?.IndexPath ?? FileIndexer.NormalizeIndexPath(displayRelativePath); + if (!authoritativeFullScan + && dbSnapshot.Files.TryGetValue( + dbRelativePath, + out var existingCandidate) + && existingCandidate.Language == "csharp" + && !TryApplyPersistedCSharpExpansionEvidence(dbRelativePath)) + { + return WriteDryRunInterrupted(options, jsonOptions); + } if (capturedTarget == null) { var pathFilter = dryIndexer.EvaluatePathFilter(f); @@ -272,6 +455,19 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) displayRelativePath, knownLanguage, cancellationToken); + if (expandedCSharpCandidatePaths.Contains(f) + && probe.Error != null) + { + MarkCSharpWorkspaceExpansionUnavailable( + "csharp_workspace_preflight_unavailable"); + } + if (!authoritativeFullScan + && probe.Supported + && probe.Language == "csharp" + && !TryApplyPersistedCSharpExpansionEvidence(dbRelativePath)) + { + return WriteDryRunInterrupted(options, jsonOptions); + } if (probe.PolicySkipped) { dryFileCount++; @@ -354,6 +550,9 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) dbRelativePath); } + if (!TryApplyProjectedCSharpPurgeEvidence()) + return WriteDryRunInterrupted(options, jsonOptions); + if (probe.Error != null) { RecordDryRunError(displayRelativePath, probe.Error); @@ -393,6 +592,8 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) projectPath, dbRelativePath); } + if (!TryApplyProjectedCSharpPurgeEvidence()) + return WriteDryRunInterrupted(options, jsonOptions); } var projectedSkip = IsDryRunLoadedFileReusable( options, @@ -454,7 +655,20 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) if (parsedEstimate.ReferenceCapHit) projectedReferenceCapHits++; csharpWorkspaceContractDetected |= - parsedEstimate.CSharpStaticInterfaceContract; + parsedEstimate.CSharpStaticInterfaceContract + || parsedEstimate.CSharpMemberReadTarget; + if (!authoritativeFullScan + && (parsedEstimate.CSharpStaticInterfaceContract + || parsedEstimate.CSharpMemberReadTarget) + && !TryExpandDryRunCSharpWorkspace( + parsedEstimate.CSharpStaticInterfaceContract + ? "source_static_interface_contract" + : "source_csharp_member_read_target")) + { + return WriteDryRunInterrupted( + options, + jsonOptions); + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -476,10 +690,23 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) langCounts[probe.Language] = langCounts.GetValueOrDefault(probe.Language) + 1; } - if (authoritativeFullScan - && projectedCSharpSkips.Count > 0 - && (csharpWorkspaceContractDetected - || csharpWorkspaceEstimateUnavailable)) + if (!authoritativeFullScan + && csharpTargetAffected + && (csharpWorkspaceEstimateUnavailable + || csharpWorkspaceEvidenceUnavailable)) + { + if (csharpWorkspaceExpansionStatus != "unavailable") + { + MarkCSharpWorkspaceExpansionUnavailable( + "csharp_workspace_preflight_unavailable"); + } + } + + if (projectedCSharpSkips.Count > 0 + && (csharpWorkspaceExpansionStatus == "applied" + || (authoritativeFullScan + && (csharpWorkspaceContractDetected + || csharpWorkspaceEstimateUnavailable)))) { projectedFileSkips -= projectedCSharpSkips.Count; projectedFileUpdates += projectedCSharpSkips.Count; @@ -497,8 +724,13 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) ? "csharp_workspace_augmentation_required" : "csharp_workspace_preflight_unavailable"); } + else if (csharpWorkspaceExpansionStatus == "applied") + { + mutationEstimates.MarkParseUnknown( + "csharp_workspace_augmentation_required"); + } - foreach (var relativePath in dryDeleteCandidates) + foreach (var relativePath in dryDeleteCandidateList) { if (candidatePathsProcessed >= dryRunPathLimit) { @@ -534,6 +766,22 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) if (candidatePathsTruncated) mutationEstimates.MarkAllUnknown("candidate_path_limit_reached"); + var projectionUnavailableReasons = new List(); + if (candidatePathsTruncated) + projectionUnavailableReasons.Add("candidate_path_limit_reached"); + if (dbSnapshot.ReadFailed) + projectionUnavailableReasons.Add("index_snapshot_unavailable"); + if (authoritativeFullScan && dryScanMetadata.HadErrors) + projectionUnavailableReasons.Add("workspace_scan_incomplete"); + if (csharpWorkspaceExpansionStatus == "unavailable" + && csharpWorkspaceExpansionReason != null) + { + projectionUnavailableReasons.Add( + csharpWorkspaceExpansionReason); + } + var projectionAuthoritative = + projectionUnavailableReasons.Count == 0; + var estimatedTableMutations = mutationEstimates.BuildValues(); var estimatedTableMutationDetails = mutationEstimates.BuildDetails(); var unknownExtensionClassification = UnknownExtensionClassifier.Classify(unknownExtensionPaths); @@ -593,7 +841,13 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) CandidatePathLimit = dryRunPathLimit, CandidatePathsProcessed = candidatePathsProcessed, CandidatePathsTruncated = candidatePathsTruncated, - TotalsLowerBound = candidatePathsTruncated, + TotalsLowerBound = !projectionAuthoritative, + ProjectionAuthoritative = projectionAuthoritative, + ProjectionUnavailableReasons = projectionUnavailableReasons, + CSharpWorkspaceExpansionStatus = + csharpWorkspaceExpansionStatus, + CSharpWorkspaceExpansionReason = + csharpWorkspaceExpansionReason, ParseEstimateFileLimit = DryRunParseEstimateFileLimit, ParseEstimateFilesProcessed = parseEstimateFilesProcessed, ParseEstimateFilesTruncated = parseEstimateFilesTruncated, @@ -622,8 +876,17 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) } else { - var lowerBound = candidatePathsTruncated ? " (truncated; totals are lower bounds)" : string.Empty; + var lowerBound = !projectionAuthoritative + ? " (projection is not authoritative; totals are lower bounds)" + : string.Empty; CommandOutputWriter.WriteLine($"Dry run: {dryFileCount} indexable files inspected{lowerBound}"); + CommandOutputWriter.WriteLine( + $" projection authoritative {(projectionAuthoritative ? "yes" : "no")}"); + CommandOutputWriter.WriteLine( + $" C# workspace expansion {csharpWorkspaceExpansionStatus}" + + (csharpWorkspaceExpansionReason == null + ? string.Empty + : $" ({csharpWorkspaceExpansionReason})")); if (unknownExtensionTotal > 0) { CommandOutputWriter.WriteLine($" unknown extensions {unknownExtensionTotal,6}{(unknownExtensionFileCountLowerBound ? " (lower bound)" : string.Empty)}"); @@ -1051,6 +1314,9 @@ private static DryRunFileProbe ProbeDryRunFile( try { DryRunFileIndexabilityValidatedForTesting?.Invoke(absolutePath); + var injectedFailure = DryRunFileProbeFailureForTesting?.Invoke(relativePath); + if (injectedFailure != null) + throw injectedFailure; var isAmbiguousExtension = FileIndexer.TryGetAmbiguousLanguageDescriptor( Path.GetExtension(absolutePath), out _); @@ -1206,6 +1472,10 @@ private static DryRunParsedMutationEstimate BuildDryRunParsedMutationEstimate( record.Lang == "csharp" && CSharpStaticInterfacePrepass .HasCSharpStaticInterfaceContractSymbol(symbols); + var csharpMemberReadTarget = + record.Lang == "csharp" + && symbols.Any( + ReferenceExtractor.IsCSharpQualifiedMemberReadTargetSymbol); if (symbols.Count > options.MaxSymbolsPerFile) { var issueCount = symbolExtraction.RegexTimeoutIssue == null ? 1 : 2; @@ -1218,7 +1488,8 @@ private static DryRunParsedMutationEstimate BuildDryRunParsedMutationEstimate( 0, SymbolCapHit: true, ReferenceCapHit: false, - csharpStaticInterfaceContract); + csharpStaticInterfaceContract, + csharpMemberReadTarget); } SymbolExtractor.ApplyFamilyScope( @@ -1238,7 +1509,8 @@ private static DryRunParsedMutationEstimate BuildDryRunParsedMutationEstimate( symbolsDroppedByKindFilter, SymbolCapHit: true, ReferenceCapHit: false, - csharpStaticInterfaceContract); + csharpStaticInterfaceContract, + csharpMemberReadTarget); } FileIndexer.ValidateSymbolLineRanges(record, symbols); @@ -1317,7 +1589,8 @@ private static DryRunParsedMutationEstimate BuildDryRunParsedMutationEstimate( symbolsDroppedByKindFilter, SymbolCapHit: false, ReferenceCapHit: referenceCapHit, - csharpStaticInterfaceContract); + csharpStaticInterfaceContract, + csharpMemberReadTarget); } private static bool IsDryRunLoadedFileReusable( @@ -1632,6 +1905,35 @@ private static DryRunDbSnapshot ReadDryRunDbSnapshot( "files", "modified"); var hasLines = DryRunColumnExists(connection, "files", "lines"); + var csharpWorkspaceSymbolColumns = new[] + { + "file_id", + "kind", + "name", + "line", + "start_line", + "start_column", + "end_line", + "body_start_line", + "body_end_line", + "signature", + "container_kind", + "container_name", + "container_qualified_name", + "family_key", + "visibility", + "return_type", + "is_metadata_target", + }; + var hasCSharpWorkspaceEvidence = hasLanguage + && hasSymbols + && csharpWorkspaceSymbolColumns.All( + column => DryRunColumnExists( + connection, + "symbols", + column)) + && DryRunIndexExists(connection, "idx_files_lang") + && DryRunIndexExists(connection, "idx_symbols_file_kind"); var hasIssueKind = hasFileIssues && DryRunColumnExists( connection, @@ -1683,6 +1985,11 @@ i.origin IS NULL ) """ : "0"; + var csharpMemberReadTargetPaths = hasCSharpWorkspaceEvidence + ? DryRunReadCSharpMemberReadTargetPaths( + connection, + cancellationToken) + : []; using var command = connection.CreateCommand(); command.CommandText = $""" @@ -1769,7 +2076,8 @@ FROM files f reader.GetInt64(10), generatedSuppressed, contentReuseEligible, - statReuseEligible); + statReuseEligible, + csharpMemberReadTargetPaths.Contains(reader.GetString(0))); } return new DryRunDbSnapshot( @@ -1781,6 +2089,7 @@ FROM files f hasSymbolReferences, hasReferenceLines, hasFileIssues, + hasCSharpWorkspaceEvidence, ReadFailed: false); } catch (SqliteException) @@ -1813,6 +2122,25 @@ FROM files f } } + private static HashSet DryRunReadCSharpMemberReadTargetPaths( + SqliteConnection connection, + CancellationToken cancellationToken) + { + var paths = new HashSet(StringComparer.Ordinal); + using var command = connection.CreateCommand(); + command.CommandText = DbWriter.CSharpMemberReadTargetWorkspaceSql; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + var symbol = DbWriter.ReadCSharpContractWorkspaceSymbol(reader); + if (ReferenceExtractor.IsCSharpQualifiedMemberReadTargetSymbol(symbol)) + paths.Add(reader.GetString(0)); + } + + return paths; + } + private static bool DryRunTableExists(SqliteConnection connection, string tableName) { using var command = connection.CreateCommand(); @@ -1821,6 +2149,14 @@ private static bool DryRunTableExists(SqliteConnection connection, string tableN return command.ExecuteScalar() != null; } + private static bool DryRunIndexExists(SqliteConnection connection, string indexName) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = @name LIMIT 1"; + SqliteCommandPolicy.Add(command, "@name", indexName); + return command.ExecuteScalar() != null; + } + private static bool DryRunColumnExists( SqliteConnection connection, string tableName, @@ -1891,7 +2227,8 @@ private readonly record struct DryRunParsedMutationEstimate( long SymbolsDroppedByKindFilter, bool SymbolCapHit, bool ReferenceCapHit, - bool CSharpStaticInterfaceContract = false); + bool CSharpStaticInterfaceContract = false, + bool CSharpMemberReadTarget = false); private readonly record struct DryRunProjectedCSharpSkip( string RelativePath, @@ -1998,6 +2335,7 @@ private sealed record DryRunDbSnapshot( bool SymbolReferencesAvailable, bool ReferenceLinesAvailable, bool FileIssuesAvailable, + bool CSharpWorkspaceEvidenceAvailable, bool ReadFailed) { internal string? SymbolKindFilterSignature @@ -2028,6 +2366,7 @@ internal bool? CSharpStaticInterfaceSourceEvidence false, false, false, + false, ReadFailed: false); public static DryRunDbSnapshot ReadFailure { get; } = Empty with @@ -2049,7 +2388,8 @@ private readonly record struct DryRunExistingFileRows( long FileIssues, bool GeneratedExtractionSuppressed, bool ContentReuseEligible, - bool StatReuseEligible); + bool StatReuseEligible, + bool HasCSharpMemberReadTarget); private readonly record struct DryRunScanMetadata( bool HadErrors, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index a1d8f59e14..861fde354c 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -113,6 +113,8 @@ public void Dispose() internal static Action? UpdateFileCommittedForTesting { get; set; } internal static Func? IndexExtractionStallTimeoutForTesting { get; set; } internal static Func? DryRunParseEstimateFailureForTesting { get; set; } + internal static Action? DryRunCSharpExpansionScanStartingForTesting { get; set; } + internal static Func? DryRunFileProbeFailureForTesting { get; set; } internal static Action? DryRunFileIndexabilityValidatedForTesting { get; set; } internal static Action? HotspotFamilyUpdateRestampReadyForCommitForTesting { get; set; } internal static Action? WriteScanCheckpointForTesting { get; set; } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index bc37b0513a..6646315a23 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -976,6 +976,12 @@ internal sealed class IndexDryRunJsonResult : IVersionedJsonResult public int CandidatePathsProcessed { get; init; } public bool CandidatePathsTruncated { get; init; } public bool TotalsLowerBound { get; init; } + public bool ProjectionAuthoritative { get; init; } + public List ProjectionUnavailableReasons { get; init; } = []; + [JsonPropertyName("csharp_workspace_expansion_status")] + public string CSharpWorkspaceExpansionStatus { get; init; } = string.Empty; + [JsonPropertyName("csharp_workspace_expansion_reason")] + public string? CSharpWorkspaceExpansionReason { get; init; } public int ParseEstimateFileLimit { get; init; } public int ParseEstimateFilesProcessed { get; init; } public bool ParseEstimateFilesTruncated { get; init; } diff --git a/src/CodeIndex/Database/DbWriter.CSharpContracts.cs b/src/CodeIndex/Database/DbWriter.CSharpContracts.cs index 8323e6c308..8322879f29 100644 --- a/src/CodeIndex/Database/DbWriter.CSharpContracts.cs +++ b/src/CodeIndex/Database/DbWriter.CSharpContracts.cs @@ -67,7 +67,7 @@ CROSS JOIN symbols s INDEXED BY idx_symbols_file_kind WHERE f.lang = 'csharp' AND " + CSharpStaticInterfaceContractMemberPredicateSql; - private const string CSharpMemberReadTargetPredicateSql = @" + internal const string CSharpMemberReadTargetPredicateSql = @" ( (s.kind = 'enum' AND s.container_kind = 'enum') OR ( @@ -449,7 +449,7 @@ WHERE s.name IN (" + parameterList + @") AND f.lang = 'csharp'"; } - private static SymbolRecord ReadCSharpContractWorkspaceSymbol(SqliteDataReader reader) + internal static SymbolRecord ReadCSharpContractWorkspaceSymbol(SqliteDataReader reader) => new() { FileId = reader.GetInt64(1), diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs index c6f76ab632..27413155ed 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs @@ -787,6 +787,890 @@ public interface IContract } } + [Theory] + [InlineData("files")] + [InlineData("commits")] + [InlineData("changed-between")] + public void Run_DryRun_ScopedCSharpExpansionMatchesExecution_Issue5225( + string scope) + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { public static Consumer Parse(string value) => new(); }\n"); + RunGit(projectRoot, "add", "contract.cs", "consumer.cs"); + RunGit(projectRoot, "commit", "-m", "initial contract"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + RunGit(projectRoot, "add", "contract.cs"); + RunGit(projectRoot, "commit", "-m", "add static contract"); + + var scopeArguments = scope switch + { + "files" => new[] { "--files", "contract.cs" }, + "commits" => new[] { "--commits", "HEAD" }, + "changed-between" => new[] + { + "--changed-between", + "HEAD~1", + "HEAD", + }, + _ => throw new ArgumentOutOfRangeException(nameof(scope)), + }; + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + var sourceBefore = TestProjectHelper.ReadTextFile( + projectRoot, + "contract.cs"); + var dryRunArguments = new[] { projectRoot } + .Concat(scopeArguments) + .Concat(["--dry-run", "--json", "--quiet"]) + .ToArray(); + + var (dryRunExitCode, dryRunJson) = + RunAndCaptureJson(dryRunArguments); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal(2, dryRunJson.GetProperty("files_total").GetInt32()); + Assert.Equal( + 2, + dryRunJson.GetProperty("candidate_paths_processed").GetInt32()); + Assert.Equal( + 2, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + 0, + dryRunJson.GetProperty("projected_file_skips").GetInt32()); + Assert.True( + dryRunJson.TryGetProperty( + "projection_authoritative", + out var projectionAuthoritative), + dryRunJson.GetRawText()); + Assert.True(projectionAuthoritative.GetBoolean()); + Assert.False( + dryRunJson.GetProperty("totals_lower_bound").GetBoolean()); + Assert.True( + dryRunJson.TryGetProperty( + "csharp_workspace_expansion_status", + out var expansionStatus), + dryRunJson.GetRawText()); + Assert.Equal("applied", expansionStatus.GetString()); + Assert.Equal( + "source_static_interface_contract", + dryRunJson.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + var fileSamples = dryRunJson.GetProperty("file_samples") + .EnumerateArray() + .Select(static value => value.GetString()) + .ToArray(); + Assert.Contains("contract.cs", fileSamples); + Assert.Contains("consumer.cs", fileSamples); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + Assert.Equal( + sourceBefore, + TestProjectHelper.ReadTextFile(projectRoot, "contract.cs")); + + var executionArguments = new[] { projectRoot } + .Concat(scopeArguments) + .Concat(["--json", "--quiet"]) + .ToArray(); + var (executionExitCode, executionJson) = + RunAndCaptureJson(executionArguments); + + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 2, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_MemberReadExpansionMatchesExecution_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "Settings.cs", + "public static class Settings { public static int Value = 1; }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "Consumer.cs", + "public class Consumer { public int Read() => Settings.Value; }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "Settings.cs", + "public static class Settings { public static int Value = 2; }\n"); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "Settings.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal( + 2, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + "applied", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.Equal( + "persisted_csharp_member_read_target", + dryRunJson.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "Settings.cs", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 2, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_PersistedMemberReadCandidateUsesExactPredicate_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "A.cs", + "public class A { public int nonstatic = 1; }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "B.cs", + "public class B { public int Value = 1; }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "A.cs", + "public class A { public int nonstatic = 2; }\n"); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "A.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + "not_required", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.True( + dryRunJson.GetProperty("projection_authoritative").GetBoolean()); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "A.cs", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_DeletedCSharpContractExpansionMatchesExecution_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { public static Consumer Parse(string value) => new(); }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.DeleteFile( + TestProjectHelper.ProjectPath(projectRoot, "contract.cs")); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "contract.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal( + "applied", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.Equal( + "persisted_csharp_contract_evidence", + dryRunJson.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "contract.cs", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("removed") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_NonCSharpScopeDoesNotExpandWorkspace_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "App.cs", + "public class App { }\n"); + TestProjectHelper.WriteTextFile(projectRoot, "notes.md", "old\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile(projectRoot, "notes.md", "new\n"); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "notes.md", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal(1, dryRunJson.GetProperty("files_total").GetInt32()); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + "not_required", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.True( + dryRunJson.GetProperty("projection_authoritative").GetBoolean()); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "notes.md", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_CSharpCleanupPurgeExpandsWorkspace_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { public static Consumer Parse(string value) => new(); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "stale.cs", + "public class Stale { }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.DeleteFile( + TestProjectHelper.ProjectPath(projectRoot, "stale.cs")); + TestProjectHelper.WriteTextFile( + projectRoot, + "stale.md", + "public class Stale { }\n"); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "stale.md", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal( + 3, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_purges").GetInt32()); + Assert.Equal( + "applied", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.Equal( + "persisted_csharp_contract_evidence", + dryRunJson.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + Assert.True( + dryRunJson.GetProperty("projection_authoritative").GetBoolean()); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "stale.md", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 3, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("removed") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_ChangedBetweenIncludesOutOfRangeStaleCSharpCleanup_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { public static Consumer Parse(string value) => new(); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "stale.cs", + "public class Stale { }\n"); + RunGit(projectRoot, "add", "contract.cs", "consumer.cs", "stale.cs"); + RunGit(projectRoot, "commit", "-m", "initial workspace"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); } // changed\n"); + RunGit(projectRoot, "add", "contract.cs"); + RunGit(projectRoot, "commit", "-m", "change contract"); + TestProjectHelper.DeleteFile( + TestProjectHelper.ProjectPath(projectRoot, "stale.cs")); + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--changed-between", + "HEAD~1", + "HEAD", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal( + 2, + dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal( + 1, + dryRunJson.GetProperty("projected_file_purges").GetInt32()); + Assert.True( + dryRunJson.GetProperty("projection_authoritative").GetBoolean()); + Assert.Equal( + "applied", + dryRunJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + + var (executionExitCode, executionJson) = RunAndCaptureJson([ + projectRoot, + "--changed-between", + "HEAD~1", + "HEAD", + "--json", + ]); + Assert.Equal(CommandExitCodes.Success, executionExitCode); + Assert.Equal( + 2, + executionJson.GetProperty("summary") + .GetProperty("updated") + .GetInt32()); + Assert.Equal( + 1, + executionJson.GetProperty("summary") + .GetProperty("removed") + .GetInt32()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_LegacyMemberReadProjectionKeepsSnapshotReadable_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "App.cs", + "public class App { public static int Value = 1; }\n"); + TestProjectHelper.WriteTextFile(projectRoot, "notes.md", "old\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var connection = new SqliteConnection( + new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString)) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = + "ALTER TABLE symbols DROP COLUMN is_metadata_target"; + command.ExecuteNonQuery(); + } + SqliteConnection.ClearAllPools(); + TestProjectHelper.WriteTextFile(projectRoot, "notes.md", "new\n"); + + var (nonCSharpExitCode, nonCSharpJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "notes.md", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, nonCSharpExitCode); + Assert.Equal( + 1, + nonCSharpJson.GetProperty("projected_file_updates").GetInt32()); + Assert.True( + nonCSharpJson.GetProperty("projection_authoritative").GetBoolean()); + Assert.NotEqual( + JsonValueKind.Null, + nonCSharpJson.GetProperty("estimated_table_mutations") + .GetProperty("files") + .ValueKind); + + TestProjectHelper.WriteTextFile( + projectRoot, + "App.cs", + "public class App { public static int Value = 2; }\n"); + var (csharpExitCode, csharpJson) = RunAndCaptureJson([ + projectRoot, + "--files", + "App.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, csharpExitCode); + Assert.Equal( + 1, + csharpJson.GetProperty("projected_file_updates").GetInt32()); + Assert.False( + csharpJson.GetProperty("projection_authoritative").GetBoolean()); + Assert.Equal( + "unavailable", + csharpJson.GetProperty("csharp_workspace_expansion_status") + .GetString()); + var unavailableReasons = csharpJson + .GetProperty("projection_unavailable_reasons") + .EnumerateArray() + .Select(static value => value.GetString()) + .ToArray(); + Assert.Contains( + "csharp_workspace_preflight_unavailable", + unavailableReasons); + Assert.DoesNotContain("index_snapshot_unavailable", unavailableReasons); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_CSharpExpansionPathCapIsNonAuthoritative_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + var arguments = new[] + { + projectRoot, + "--files", + "contract.cs", + "--dry-run", + "--dry-run-path-limit", + "1", + }; + + var (exitCode, json) = RunAndCaptureJson( + arguments.Concat(["--json"]).ToArray()); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal( + 1, + json.GetProperty("candidate_paths_processed").GetInt32()); + Assert.True( + json.GetProperty("candidate_paths_truncated").GetBoolean()); + Assert.True(json.GetProperty("totals_lower_bound").GetBoolean()); + Assert.False( + json.GetProperty("projection_authoritative").GetBoolean()); + Assert.Contains( + "candidate_path_limit_reached", + json.GetProperty("projection_unavailable_reasons") + .EnumerateArray() + .Select(static value => value.GetString())); + Assert.Equal( + "applied", + json.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.True(json.GetProperty("file_samples_truncated").GetBoolean()); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + + var (humanExitCode, humanOutput) = + RunAndCaptureDryRunHuman(arguments); + Assert.Equal(CommandExitCodes.Success, humanExitCode); + Assert.Contains( + "projection authoritative no", + humanOutput, + StringComparison.Ordinal); + Assert.Contains( + "C# workspace expansion applied (source_static_interface_contract)", + humanOutput, + StringComparison.Ordinal); + Assert.Contains( + "totals are lower bounds", + humanOutput, + StringComparison.Ordinal); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_CSharpExpansionErrorIsNonAuthoritative_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "app.cs", + "public class App { }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "app.cs", + "public class App { public void Changed() { } }\n"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + IndexCommandRunner.DryRunParseEstimateFailureForTesting = static _ => + new InvalidOperationException("injected issue5225 parse failure"); + + var (exitCode, json) = RunAndCaptureJson([ + projectRoot, + "--files", + "app.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.False( + json.GetProperty("projection_authoritative").GetBoolean()); + Assert.True(json.GetProperty("totals_lower_bound").GetBoolean()); + Assert.Equal( + "unavailable", + json.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.Equal( + "csharp_workspace_preflight_unavailable", + json.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + Assert.Contains( + "csharp_workspace_preflight_unavailable", + json.GetProperty("projection_unavailable_reasons") + .EnumerateArray() + .Select(static value => value.GetString())); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + } + finally + { + IndexCommandRunner.DryRunParseEstimateFailureForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_ExpandedCSharpProbeErrorIsNonAuthoritative_Issue5225() + { + var projectRoot = CreateTempProject(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { public static Consumer Parse(string value) => new(); }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "unreadable.cs", + "public class Unreadable { }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + IndexCommandRunner.DryRunFileProbeFailureForTesting = + static path => path == "unreadable.cs" + ? new UnauthorizedAccessException("injected issue5225 read failure") + : null; + + var (exitCode, json) = RunAndCaptureJson([ + projectRoot, + "--files", + "contract.cs", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.False( + json.GetProperty("projection_authoritative").GetBoolean()); + Assert.True(json.GetProperty("totals_lower_bound").GetBoolean()); + Assert.Equal( + "unavailable", + json.GetProperty("csharp_workspace_expansion_status") + .GetString()); + Assert.Equal( + "csharp_workspace_preflight_unavailable", + json.GetProperty("csharp_workspace_expansion_reason") + .GetString()); + Assert.Contains( + "csharp_workspace_preflight_unavailable", + json.GetProperty("projection_unavailable_reasons") + .EnumerateArray() + .Select(static value => value.GetString())); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + } + finally + { + IndexCommandRunner.DryRunFileProbeFailureForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_CSharpExpansionHonorsCancellation_Issue5225() + { + var projectRoot = CreateTempProject(); + using var cancellation = new CancellationTokenSource(); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { }\n"); + TestProjectHelper.WriteTextFile( + projectRoot, + "consumer.cs", + "public sealed class Consumer : IContract { }\n"); + var (indexExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + TestProjectHelper.WriteTextFile( + projectRoot, + "contract.cs", + "public interface IContract where T : IContract { static abstract T Parse(string value); }\n"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var databaseFilesBefore = ReadDatabaseFileSetFingerprint(dbPath); + IndexCommandRunner.DryRunCSharpExpansionScanStartingForTesting = + cancellation.Cancel; + + var (exitCode, json) = RunAndCaptureJson([ + projectRoot, + "--files", + "contract.cs", + "--dry-run", + "--json", + ], cancellation); + + Assert.Equal(CommandExitCodes.Interrupted, exitCode); + Assert.Equal("error", json.GetProperty("status").GetString()); + Assert.Equal( + CommandErrorCodes.Interrupted, + json.GetProperty("error_code").GetString()); + Assert.Equal( + databaseFilesBefore, + ReadDatabaseFileSetFingerprint(dbPath)); + } + finally + { + IndexCommandRunner.DryRunCSharpExpansionScanStartingForTesting = null; + DeleteDirectory(projectRoot); + } + } + + private (int ExitCode, string Output) RunAndCaptureDryRunHuman( + string[] args) + { + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var writer = new StringWriter(); + try + { + Console.SetOut(writer); + var exitCode = IndexCommandRunner.Run(args, _jsonOptions); + return (exitCode, writer.ToString()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + [Fact] public void Run_DryRun_HotspotMarkerChangeInvalidatesFamilyReuse_Issue4893() {