diff --git a/.github/scripts/run-dotnet-tests.ps1 b/.github/scripts/run-dotnet-tests.ps1 index d5b3d09bf..509eb5d0e 100644 --- a/.github/scripts/run-dotnet-tests.ps1 +++ b/.github/scripts/run-dotnet-tests.ps1 @@ -76,10 +76,20 @@ function Invoke-TestRun { $runArgs += @("--filter", $TestFilter) } - $capturedOutput = [System.Collections.Generic.List[string]]::new() + [int]$failureLogTailLineLimit = 2000 + $retainedOutputTail = [System.Collections.Generic.Queue[string]]::new($failureLogTailLineLimit) + [long]$totalOutputLineCount = 0 + $testSessionTimedOut = $false dotnet @runArgs 2>&1 | ForEach-Object { $line = [string]$_ - $capturedOutput.Add($line) + $totalOutputLineCount++ + if ($line.IndexOf("test run timeout", [StringComparison]::OrdinalIgnoreCase) -ge 0) { + $testSessionTimedOut = $true + } + if ($retainedOutputTail.Count -ge $failureLogTailLineLimit) { + [void]$retainedOutputTail.Dequeue() + } + [void]$retainedOutputTail.Enqueue($line) Write-Host $line } @@ -87,10 +97,20 @@ function Invoke-TestRun { if ($exitCode -ne 0) { $logDirectory = Split-Path -Parent $LogPath New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null - [System.IO.File]::WriteAllLines($LogPath, [string[]]$capturedOutput) + $failureLogLines = [System.Collections.Generic.List[string]]::new($retainedOutputTail.Count + 1) + $omittedOutputLineCount = $totalOutputLineCount - $retainedOutputTail.Count + if ($omittedOutputLineCount -gt 0) { + [void]$failureLogLines.Add( + "[ci] Test output truncated: retained final $($retainedOutputTail.Count) of $totalOutputLineCount lines; $omittedOutputLineCount earlier line(s) were streamed live and omitted from this artifact.") + } + [void]$failureLogLines.AddRange($retainedOutputTail.ToArray()) + [System.IO.File]::WriteAllLines($LogPath, [string[]]$failureLogLines) } - return [int]$exitCode + return [pscustomobject]@{ + ExitCode = [int]$exitCode + TestSessionTimedOut = [bool]$testSessionTimedOut + } } function Merge-TestFilters { @@ -168,19 +188,19 @@ function Get-RetryFilterDecision { } $firstLogPath = Join-Path $resultsDirectory "test-output-first.txt" -$firstExitCode = Invoke-TestRun -LogPath $firstLogPath -ResultFileName "test_results_first.trx" -IncludeCoverage $includeCoverage -IncludeCrashDiagnostics $true -TestFilter $BaseFilter -if ($firstExitCode -eq 0) { +$firstRunResult = Invoke-TestRun -LogPath $firstLogPath -ResultFileName "test_results_first.trx" -IncludeCoverage $includeCoverage -IncludeCrashDiagnostics $true -TestFilter $BaseFilter +if ($firstRunResult.ExitCode -eq 0) { exit 0 } Write-StepOutput -Name "summarize" -Value "true" -if (Select-String -Path $firstLogPath -SimpleMatch "test run timeout" -Quiet) { +if ($firstRunResult.TestSessionTimedOut) { Write-Warning "Initial test run hit TestSessionTimeout; skipping flaky retry to keep CI bounded. Inspect uploaded TRX/blame artifacts." - exit $firstExitCode + exit $firstRunResult.ExitCode } -Write-Warning "Initial test run failed with exit code $firstExitCode. Rerunning once to classify possible flakiness." +Write-Warning "Initial test run failed with exit code $($firstRunResult.ExitCode). Rerunning once to classify possible flakiness." if ($includeCoverage) { Write-Host "Skipping XPlat Code Coverage on the flaky-classification retry." } @@ -200,12 +220,12 @@ else { Write-Host "Focused retry is unavailable ($($retryFilterDecision.reason)); using the $fallbackScope retry fallback." } $retryLogPath = Join-Path $resultsDirectory "test-output-retry.txt" -$retryExitCode = Invoke-TestRun -LogPath $retryLogPath -ResultFileName "test_results_retry.trx" -IncludeCoverage $false -IncludeCrashDiagnostics $false -TestFilter $retryFilter -if ($retryExitCode -eq 0) { +$retryRunResult = Invoke-TestRun -LogPath $retryLogPath -ResultFileName "test_results_retry.trx" -IncludeCoverage $false -IncludeCrashDiagnostics $false -TestFilter $retryFilter +if ($retryRunResult.ExitCode -eq 0) { "Initial test run failed, but the single retry passed. Retry scope: $retryScope. Treat this run as flaky and inspect TRX/blame artifacts." | Set-Content -Encoding UTF8 -Path (Join-Path $resultsDirectory "flaky-retry.txt") Write-Warning "Tests passed on retry; uploaded TestResults include flaky-retry.txt." exit 0 } -exit $retryExitCode +exit $retryRunResult.ExitCode diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 363ad127d..c4c2d174a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,13 @@ jobs: with: dotnet-version: 9.0.301 cache: true - cache-dependency-path: '**/packages.lock.json' + cache-dependency-path: | + src/CodeIndex/packages.lock.json + tests/CodeIndex.HookIsolationFixture/packages.lock.json + tests/CodeIndex.Tests/packages.lock.json + tools/CodeIndex.Changelog/packages.lock.json + tools/CodeIndex.PackageNormalize/packages.lock.json + tools/CodeIndex.TestTelemetry/packages.lock.json - name: Initialize CodeQL uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 703cad093..7311a36af 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -130,7 +130,7 @@ jobs: ~\AppData\Local\NuGet\packages # Locked restore keeps package inputs honest; avoid evicting the cache for project-only test edits. # locked restore がパッケージ入力を検証するため、テスト用 project だけの変更ではキャッシュを失効させない。 - key: ${{ runner.os }}-dotnet-nuget-${{ hashFiles('**/packages.lock.json', 'global.json') }} + key: ${{ runner.os }}-dotnet-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json', 'tests/CodeIndex.HookIsolationFixture/packages.lock.json', 'tests/CodeIndex.Tests/packages.lock.json', 'tools/CodeIndex.Changelog/packages.lock.json', 'tools/CodeIndex.PackageNormalize/packages.lock.json', 'tools/CodeIndex.TestTelemetry/packages.lock.json') }} # --locked-mode requires every resolved package to match the committed # packages.lock.json so an unexpected transitive bump (including silent @@ -202,6 +202,7 @@ jobs: TestResults/**/*.trx TestResults/**/*.txt TestResults/**/*.xml + !TestResults/**/coverage.cobertura.xml - name: Upload diagnostic dumps if: failure() && steps.test.outcome != 'skipped' @@ -218,7 +219,7 @@ jobs: TestResults/**/*.hangdump - name: Upload coverage reports - if: always() && matrix.collect_coverage && steps.test.outcome != 'skipped' + if: always() && matrix.collect_coverage && steps.test.outcome != 'skipped' && hashFiles('TestResults/**/coverage.cobertura.xml') != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Coverage-${{ matrix.os }}-${{ matrix.test-framework }}-${{ matrix.test-shard }} @@ -228,11 +229,11 @@ jobs: path: TestResults/**/coverage.cobertura.xml - name: Publish - if: matrix.primary_lane + if: matrix.primary_lane && github.event_name != 'pull_request' run: dotnet publish src/CodeIndex/CodeIndex.csproj --configuration Release --no-build --no-restore --output publish - name: Upload build artifact - if: matrix.primary_lane + if: matrix.primary_lane && github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CodeIndex diff --git a/.github/workflows/license-policy.yml b/.github/workflows/license-policy.yml index 47c0608df..a72dd7a17 100644 --- a/.github/workflows/license-policy.yml +++ b/.github/workflows/license-policy.yml @@ -12,21 +12,15 @@ on: - 'TRADEMARKS.md' - 'README.md' - 'USER_GUIDE.md' - - 'DEVELOPER_GUIDE.md' - 'DISTRIBUTION.md' - 'docs/NUGET_README.md' - - 'MAINTAINERS.md' - - 'CONTRIBUTING.md' - 'src/CodeIndex/CodeIndex.csproj' - 'src/CodeIndex/Cli/ConsoleUi.cs' - - 'install.sh' - 'install_modules/20-installer.sh' - 'install_modules/40-uninstall.sh' - '.github/workflows/release.yml' - '.github/workflows/license-policy.yml' - 'tests/CodeIndex.Tests/LicensePolicyTests.cs' - - 'tests/CodeIndex.Tests/InstallScriptTests.cs' - - 'tests/CodeIndex.Tests/ReleaseWorkflowTests.cs' pull_request: branches: - main @@ -38,21 +32,15 @@ on: - 'TRADEMARKS.md' - 'README.md' - 'USER_GUIDE.md' - - 'DEVELOPER_GUIDE.md' - 'DISTRIBUTION.md' - 'docs/NUGET_README.md' - - 'MAINTAINERS.md' - - 'CONTRIBUTING.md' - 'src/CodeIndex/CodeIndex.csproj' - 'src/CodeIndex/Cli/ConsoleUi.cs' - - 'install.sh' - 'install_modules/20-installer.sh' - 'install_modules/40-uninstall.sh' - '.github/workflows/release.yml' - '.github/workflows/license-policy.yml' - 'tests/CodeIndex.Tests/LicensePolicyTests.cs' - - 'tests/CodeIndex.Tests/InstallScriptTests.cs' - - 'tests/CodeIndex.Tests/ReleaseWorkflowTests.cs' workflow_dispatch: concurrency: @@ -125,7 +113,13 @@ jobs: 8.0.413 9.0.301 cache: true - cache-dependency-path: '**/packages.lock.json' + cache-dependency-path: | + src/CodeIndex/packages.lock.json + tests/CodeIndex.HookIsolationFixture/packages.lock.json + tests/CodeIndex.Tests/packages.lock.json + tools/CodeIndex.Changelog/packages.lock.json + tools/CodeIndex.PackageNormalize/packages.lock.json + tools/CodeIndex.TestTelemetry/packages.lock.json - name: Restore license policy test dependencies run: dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=net8.0 --locked-mode diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 695076605..52006de77 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -25,20 +25,24 @@ jobs: 8.0.413 9.0.301 - - name: Cache Stryker tool and NuGet packages - id: mutation-cache + - name: Cache NuGet packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - path: | - ~/.dotnet/tools - ~/.nuget/packages - key: ${{ runner.os }}-mutation-stryker-4.14.0-${{ hashFiles('**/packages.lock.json', 'global.json') }} + path: ~/.nuget/packages + key: ${{ runner.os }}-mutation-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json', 'tests/CodeIndex.HookIsolationFixture/packages.lock.json', 'tests/CodeIndex.Tests/packages.lock.json', 'tools/CodeIndex.Changelog/packages.lock.json', 'tools/CodeIndex.PackageNormalize/packages.lock.json', 'tools/CodeIndex.TestTelemetry/packages.lock.json') }} + + - name: Cache Stryker tool + id: stryker-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.dotnet/tools + key: ${{ runner.os }}-mutation-stryker-4.14.0 - name: Restore run: dotnet restore CodeIndex.sln --locked-mode - name: Install Stryker.NET - if: steps.mutation-cache.outputs.cache-hit != 'true' + if: steps.stryker-cache.outputs.cache-hit != 'true' run: dotnet tool update --global dotnet-stryker --version 4.14.0 - name: Run DbWriter mutation tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7921323ee..758797f16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: ref: ${{ needs.preflight.outputs.ref }} - name: Configure Windows test host - if: runner.os == 'Windows' + if: runner.os == 'Windows' && !matrix.cross_compile shell: pwsh run: ./.github/scripts/configure-windows-test-host.ps1 -Workspace "${{ github.workspace }}" @@ -119,13 +119,24 @@ jobs: uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 9.0.301 - - name: Cache NuGet packages + + - name: Cache native NuGet packages + if: ${{ !matrix.cross_compile }} + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.nuget/packages + ~\AppData\Local\NuGet\packages + key: ${{ runner.os }}-release-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json', 'tests/CodeIndex.HookIsolationFixture/packages.lock.json', 'tests/CodeIndex.Tests/packages.lock.json', 'tools/CodeIndex.Changelog/packages.lock.json', 'tools/CodeIndex.PackageNormalize/packages.lock.json', 'tools/CodeIndex.TestTelemetry/packages.lock.json') }} + + - name: Cache cross-compile NuGet packages + if: matrix.cross_compile uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.nuget/packages ~\AppData\Local\NuGet\packages - key: ${{ runner.os }}-release-nuget-${{ hashFiles('**/packages.lock.json', 'global.json') }} + key: ${{ runner.os }}-release-cross-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json') }} # --locked-mode requires every resolved package to match the committed # packages.lock.json so an unexpected transitive bump (including silent @@ -161,21 +172,25 @@ jobs: # 依存ツリーと SQLitePCLRaw のネイティブアセットを列挙するので RID 間で # 内容は同一)。upstream の major 変更で release workflow が黙って壊れない # よう、安定メジャーをピン留めする。 + - name: Cache CycloneDX SBOM tool (linux-x64 only) + if: matrix.rid == 'linux-x64' + id: cyclonedx-tool-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.dotnet/tools + key: ${{ runner.os }}-cyclonedx-6.2.0 + - name: Install CycloneDX SBOM tool (linux-x64 only) + if: matrix.rid == 'linux-x64' && steps.cyclonedx-tool-cache.outputs.cache-hit != 'true' + run: dotnet tool install --global CycloneDX --version 6.2.0 + + # actions/setup-dotnet does not put $HOME/.dotnet/tools on PATH, including + # after a cache hit, so publish it for every linux-x64 run. + # cache hit 後も actions/setup-dotnet は $HOME/.dotnet/tools を PATH に + # 追加しないため、linux-x64 の各 run で明示的に公開する。 + - name: Add CycloneDX SBOM tool to PATH (linux-x64 only) if: matrix.rid == 'linux-x64' - # actions/setup-dotnet@v4 does not put $HOME/.dotnet/tools on PATH - # automatically, so we append it via $GITHUB_PATH for every subsequent - # step. Without this, the next step would fail with - # "dotnet-CycloneDX: command not found" on GitHub-hosted runners even - # though the tool is installed correctly under $HOME/.dotnet/tools. - # actions/setup-dotnet@v4 は $HOME/.dotnet/tools を自動では PATH に - # 加えないため、$GITHUB_PATH 経由で明示的に追加して後続 step から - # `dotnet-CycloneDX` を直接呼べるようにする。これを忘れると、ツール - # 自体は $HOME/.dotnet/tools に正しくインストールされていても、次の - # step が `command not found` で落ちる。 - run: | - dotnet tool install --global CycloneDX --version 6.2.0 - echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" - name: Generate CycloneDX SBOM (linux-x64 only) if: matrix.rid == 'linux-x64' @@ -821,6 +836,10 @@ jobs: dotnet-version: | 8.0.413 9.0.301 + cache: true + cache-dependency-path: | + src/CodeIndex/packages.lock.json + tools/CodeIndex.PackageNormalize/packages.lock.json - name: Extract version from tag id: version diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9386c348b..d7efd20c1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -22,7 +22,7 @@ Top-level task wrappers: | `make test` | Run tests through the repository wrapper. | | `make lint` | Run formatting/lint validation. | | `make coverage` | Run coverage workflow. | -| `make mcp-smoke` | Run the MCP smoke workflow. | +| `make mcp-smoke` | Build once and run MCP help from that configuration's output. | Use `FRAMEWORK=net9.0 make test` to match the net9 CI lane. On systems without `make`, run the same tasks as `./dev.sh build`, `./dev.sh test`, and so on. @@ -40,7 +40,7 @@ Development contracts: | Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. Completion candidates come from `CliFlagSchema`: `ValueKind` / `CommandValueKinds` select contextual path, project, repository, language, and symbol-kind behavior; `ValueDomain` / `CommandValueDomains` define exhaustive finite choices; and `SupplementalCompletionValues` preserves real reserved literals for mixed inputs such as path-or-`github`. Display placeholders such as `` are metavariables and must never be parsed into candidates. When command or flag schema changes, update completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | | Target frameworks | The production CLI and NuGet tool packaging target `net8.0`. The test project multi-targets `net8.0;net9.0`, and CI runs the test suite on both frameworks across Linux, Windows, and macOS. Use a .NET SDK that can restore and run both target frameworks when validating the full CI-equivalent test matrix. | | SDK selection | `global.json` pins the repository SDK to `9.0.301` with `rollForward` disabled. CI installs both `8.0.413` and `9.0.301` explicitly: `8.0.413` provides the `net8.0` runtime lane, while `9.0.301` is the selected SDK for restore, build, test, publish, and changelog validation. When rolling SDKs, update `global.json`, every `actions/setup-dotnet` version list, the Docker build image, and this guide together. | -| GitHub Actions policy | Workflows pin hosted runners to versioned labels (`ubuntu-24.04`, `windows-2022`, `macos-14`), keep the top-level `contents` permission read-only by default, limit `continue-on-error` to failure-path diagnostic artifact upload, give every upload artifact explicit retention, bound every artifact download by pattern and path, and scope cache keys to workflow + runner OS + `packages.lock.json` / `global.json` without broad restore-key fallbacks. `CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` enforces this checklist. | +| GitHub Actions policy | Workflows pin hosted runners to versioned labels (`ubuntu-24.04`, `windows-2022`, `macos-14`), keep the top-level `contents` permission read-only by default, limit `continue-on-error` to failure-path diagnostic artifact upload, give every upload artifact explicit retention, and bound every artifact download by pattern and path. NuGet cache keys use workflow + runner OS + the exact reachable `packages.lock.json` restore graph, explicitly exclude `global.json` and unrelated locks, avoid broad restore-key fallbacks, and keep version-pinned tool caches separate. `PackagesLockTests` owns the exact restore/cache graph contract; `CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` enforces the general workflow checklist. | | Test diagnostics | CI uses `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings` plus VSTest blame crash/hang collection and a bounded one-time retry to distinguish repeatable failures from pass-on-retry flakes. The Build and Test workflow splits `ubuntu-24.04` / `net8.0` into complementary coverage shards; Windows and macOS use the same complementary net8 split without coverage overhead, while the Ubuntu net9 compatibility lane runs the full suite. For test suite structure, shared helpers, state-isolation rules, timeout diagnostics, and test-writing conventions, see [TESTING_GUIDE.md](TESTING_GUIDE.md). | | Mutation testing | The weekly `Mutation testing` workflow runs Stryker.NET against `src/CodeIndex/Database/DbWriter.cs` using `stryker-config.json`. Keep this scope focused on transaction, savepoint, rollback, and batch-write behavior unless the runtime budget is intentionally expanded. The workflow caches the pinned `dotnet-stryker` 4.14.0 tool and NuGet packages, updates the tool only on cache misses, and keeps mutation score gates at high 75, low 70, and break 65 so changes that weaken rollback or savepoint coverage fail outside the regular PR test path. | @@ -3511,7 +3511,7 @@ For symmetry, the MCP server no longer echoes raw `Exception.Message` content in | `make test` | repository wrapper 経由でテスト実行。 | | `make lint` | formatting / lint 検証を実行。 | | `make coverage` | coverage workflow を実行。 | -| `make mcp-smoke` | MCP smoke workflow を実行。 | +| `make mcp-smoke` | 1回ビルドし、そのconfigurationの出力からMCP helpを実行。 | net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使います。`make` がない 環境では、同じタスクを `./dev.sh build`、`./dev.sh test` などで実行します。 @@ -3529,7 +3529,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。completion candidate は `CliFlagSchema` を基準にし、`ValueKind` / `CommandValueKinds` が path、project、repository、language、symbol kind の文脈別動作を選び、`ValueDomain` / `CommandValueDomains` は網羅的な有限候補を定義し、`SupplementalCompletionValues` は path または `github` のような混合入力で実在する予約 literal を維持します。`` のような表示用 placeholder は metavariable であり、候補へ分解してはいけません。command や flag の schema を変えた場合は completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | | target framework | 製品版 CLI と NuGet tool packaging は `net8.0` を対象にしています。test project は `net8.0;net9.0` の multi-target で、CI は Linux、Windows、macOS の各 lane で両方の framework に対して test suite を実行します。CI 相当の full matrix を検証する場合は、両方の target framework を restore / 実行できる .NET SDK を使ってください。 | | SDK selection | `global.json` は repository SDK を `9.0.301` に固定し、`rollForward` を無効化します。CI は `8.0.413` と `9.0.301` を明示的に install します。`8.0.413` は `net8.0` runtime lane を提供し、`9.0.301` は restore、build、test、publish、changelog 検証で選択される SDK です。SDK を更新する場合は、`global.json`、すべての `actions/setup-dotnet` version list、Docker build image、この guide を同じ変更で更新してください。 | -| GitHub Actions policy | workflow は hosted runner を version 付き label(`ubuntu-24.04`、`windows-2022`、`macos-14`)に固定し、top-level の `contents` permission は既定で read-only に保ちます。`continue-on-error` は failure path の diagnostic artifact upload に限定し、すべての upload artifact に明示的な retention を付け、artifact download は pattern と path で境界を絞ります。cache key は workflow + runner OS + `packages.lock.json` / `global.json` に scope し、広い restore-key fallback は使いません。`CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` がこの checklist を強制します。 | +| GitHub Actions policy | workflow は hosted runner を version 付き label(`ubuntu-24.04`、`windows-2022`、`macos-14`)に固定し、top-level の `contents` permission は既定で read-only に保ちます。`continue-on-error` は failure path の diagnostic artifact upload に限定し、すべての upload artifact に明示的な retention を付け、artifact download は pattern と path で境界を絞ります。NuGet cache key は workflow + runner OS + 到達可能な `packages.lock.json` の厳密な restore graph を使い、`global.json` と無関係な lock を明示的に除外し、広い restore-key fallback を避け、version 固定 tool cache を分離します。`PackagesLockTests` が厳密な restore/cache graph 契約を所有し、`CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` が一般的な workflow checklist を強制します。 | | test diagnostics | CI は `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings` と VSTest の crash/hang blame collection、上限付きの 1 回だけの retry を使い、再現性のある失敗と retry で通る flake を区別します。Build and Test workflow は `ubuntu-24.04` / `net8.0` を補完的な coverage shard に分割します。Windows と macOS も同じ補完的な net8 分割を coverage overhead なしで使い、Ubuntu net9 compatibility lane は full suite を実行します。test suite の構成、共有 helper、state-isolation rule、timeout diagnostics、test-writing convention については [TESTING_GUIDE.md#テストガイド](TESTING_GUIDE.md#テストガイド) を参照してください。 | | mutation testing | weekly の `Mutation testing` workflow は `stryker-config.json` を使い、`src/CodeIndex/Database/DbWriter.cs` に対して Stryker.NET を実行します。runtime budget を意図的に広げる場合を除き、transaction、savepoint、rollback、batch-write behavior に scope を集中させてください。workflow は pinned `dotnet-stryker` 4.14.0 tool と NuGet package を cache し、cache miss のときだけ tool を update します。mutation score gate は high 75、low 70、break 65 で、rollback や savepoint coverage を弱める変更は通常の PR test path の外で失敗します。 | diff --git a/README.md b/README.md index 2c0a117a3..b14ecb765 100644 --- a/README.md +++ b/README.md @@ -165,11 +165,11 @@ visible here as a compact compatibility index. | Runtime trust and permissions | `trust_overrides`, `git_executable`, `path_case_sensitive`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`. | | Check context and run diagnostics | `stale_after_seconds`, `index_age_seconds`, `query_context.check_mode`, `query_context.stale_after_seconds`, `process`, `last_index_run`, `last_workspace_freshened_at`, `last_failed_or_partial_index_run`. | | Last-run detail | `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_index_run.reference_extraction_cap_hits`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`. | -| SQLite and maintenance | `sqlite_connection_policy`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings`, `prepared_command_cache`, `maintenance_guidance`, `maintenance_guidance.fts_optimization`. | +| SQLite and maintenance | `sqlite_connection_policy`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings`, `prepared_command_cache`, `maintenance_guidance`, `maintenance_guidance.fts_optimization`, `threshold_writes`, `observed_writes`. | | WAL checkpoint diagnostics | `read_only_fallback`, `wal_checkpoint_attempted`, `wal_checkpoint_succeeded`, `wal_checkpoint_skipped_reason`, `wal_checkpoint_failure_reason`, `wal_checkpoint_busy`, `wal_checkpoint_log_page_count`, `wal_checkpoint_checkpointed_page_count`, `wal_checkpoint_remaining_page_count`, `read_only_immutable_fallback`, `wal_stale_snapshot_risk`, `wal_stale_snapshot_reason`. | | Database size attribution | `database_size_attribution`. | | Remediation | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | -| MCP-only session diagnostics | `mcp_session`, `mcp_session.metrics`, `mcp_session.audit_log`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`. | +| 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`. | Use `cdidx status --explain ` for bounded field guidance. Detailed semantics, repair-action structure, readiness degradation, SQLite/WAL handling, @@ -360,11 +360,11 @@ field group を表に残します。 | runtime trust / permissions | `trust_overrides`、`git_executable`、`path_case_sensitive`、`data_dir_mode`、`db_file_mode`、`database_permission_policy`、`database_permission_diagnostics`、`mac_profile`、`mac_profile_diagnostics`。 | | check context / run diagnostics | `stale_after_seconds`、`index_age_seconds`、`query_context.check_mode`、`query_context.stale_after_seconds`、`process`、`last_index_run`、`last_workspace_freshened_at`、`last_failed_or_partial_index_run`。 | | last-run detail | `last_index_run.bytes_read_skipped_file_count`、`last_index_run.bytes_read_incomplete`、`last_index_run.diagnostics`、`last_index_run.diagnostic_count`、`last_index_run.diagnostics_truncated`、`last_index_run.reference_extraction_cap_hits`、`last_failed_or_partial_index_run.progress_persisted`、`last_failed_or_partial_index_run.recovery_hint`、`last_failed_or_partial_index_run.file_errors`。 | -| SQLite / maintenance | `sqlite_connection_policy`、`db_size_bytes`、`wal_size_bytes`、`db_pragma_settings`、`prepared_command_cache`、`maintenance_guidance`、`maintenance_guidance.fts_optimization`。 | +| SQLite / maintenance | `sqlite_connection_policy`、`db_size_bytes`、`wal_size_bytes`、`db_pragma_settings`、`prepared_command_cache`、`maintenance_guidance`、`maintenance_guidance.fts_optimization`、`threshold_writes`、`observed_writes`。 | | WAL checkpoint diagnostics | `read_only_fallback`、`wal_checkpoint_attempted`、`wal_checkpoint_succeeded`、`wal_checkpoint_skipped_reason`、`wal_checkpoint_failure_reason`、`wal_checkpoint_busy`、`wal_checkpoint_log_page_count`、`wal_checkpoint_checkpointed_page_count`、`wal_checkpoint_remaining_page_count`、`read_only_immutable_fallback`、`wal_stale_snapshot_risk`、`wal_stale_snapshot_reason`。 | | database size attribution | `database_size_attribution`。 | | remediation | `degraded_root_cause`、`degraded_reason`、`recommended_action`、`alternative_action`、`readiness_degradations`、`repair_commands`。 | -| MCP-only session diagnostics | `mcp_session`、`mcp_session.metrics`、`mcp_session.audit_log`、`mcp.rate_limit.bucket_limit`、`mcp.rate_limit.bucket_limit_rejection_count`。 | +| 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`。 | 上限付きの field guidance は `cdidx status --explain ` で確認できます。 repair action、readiness degradation、SQLite/WAL、MCP diagnostic の詳細は diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6e809b867..4596d1f08 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -46,16 +46,18 @@ Use the full suite by default. Use targeted filters only while iterating locally - Incremental TypeScript augmentation coverage tracks both old and new interface names across full scan, scoped update, and MCP indexing. Rebuild only declarations sharing those exact names, delete stale augmentation rows for removed names, and batch name predicates below SQLite's parameter budget; retain full-rebuild fallback for fresh/rebuild runs, broad dirty-name sets, project-root or contract-version changes, and an upfront forced extractor refresh. A runtime JavaScript/TypeScript configuration refresh must track every refreshed file's old and new names, with the adaptive broad-set fallback remaining authoritative. Full-fallback and symbols-only paths must keep rollback-safe readiness-only tracking without materializing interface names. Coverage must keep 1,000 untouched singleton interfaces outside the candidate set, cross the name-batch boundary with 1,001 requested names, switch a 5,001-name request back to the full path, preserve unrelated merged references and module classification from non-dirty indexed interface names when disk fallback is unavailable, detect stale-file purge plus a persisted TypeScript-to-non-TypeScript language transition before replacement in all three indexing paths, clear readiness at most once after the latest rollback and skip further checks while that clear is durable, and interrupt synchronous SQLite work plus roll back augmentation rows on cancellation. - Index-finalization readiness coverage keeps reference-cap reads valid inside an active writer transaction, preserves an unavailable last-run cap snapshot when scoped updates inherit a missing IssuesReady flag, and checks mixed C#/VB partial hotspot-family rows with one grouped reader initialization. Preserve both language results and the degraded-readiness gate when changing readiness SQL; do not replace them with wall-clock thresholds. - CI runs the test project through `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings`, enables VSTest blame crash and hang collection, applies a 75-minute session timeout plus 60-second xUnit long-running diagnostics, and reruns the suite once after an initial failure. If the retry passes, CI uploads `TestResults/flaky-retry.txt` with the TRX and blame artifacts so the run is treated as suspect instead of silently trusted. - TRX telemetry summaries and test-result artifact uploads run only for failed or pass-on-retry lanes, not for clean first-pass success lanes; streamed test output is also written under `TestResults` only after a failed run needs upload/timeout inspection, and that failure-log directory is created only on the failure path. The telemetry summarizer and retry-filter command launch the already-built Release helper directly, so failure diagnostics do not repeat restore or build evaluation. + TRX telemetry summaries and test-result artifact uploads run only for failed or pass-on-retry lanes, not for clean first-pass success lanes. The complete test stream remains visible in the step log, while a failed attempt writes only its bounded final tail under `TestResults`; a truncated artifact starts with retained, total, and omitted line counts, and the failure-log directory is still created only on the failure path. Test-session timeout detection occurs while streaming and does not depend on the retained tail. The telemetry summarizer and retry-filter command launch the already-built Release helper directly, so failure diagnostics do not repeat restore or build evaluation. XPlat Code Coverage collection is limited to the `ubuntu-24.04` / `net8.0` shards. Those coverage shards, plus Windows and macOS `net8.0`, split `IndexCommandRunnerTests` from the complementary remainder into separate processes; each filter pair forms the complete suite while reducing wall-clock time. The Ubuntu `net9.0` compatibility lane remains one full-suite process. Initial runs and full fallbacks retain the lane filter, focused retries intersect it with the failed-test filter, and test artifacts include the shard identity. OS coverage runs on `net8.0`, the production CLI target, while `net9.0` compatibility coverage runs on `ubuntu-24.04` only. Test execution runs with `--no-build` after locked restore and Release build steps: the primary Ubuntu coverage shard restores the full solution for audit and publish coverage, then builds `tests/CodeIndex.Tests/CodeIndex.Tests.csproj` for the matrix framework; non-primary lanes restore only that test project's matrix framework with `RestoreTargetFrameworks` before the same per-framework build. The `net8.0` lanes retain both pinned SDKs because the 9.0 SDK selected by `global.json` builds the project while the 8.0 SDK supplies the test runtime. The `net9.0` compatibility lane installs only `9.0.301`, avoiding its unused 8.0 SDK/runtime download. - `CodeIndex.Tests.runsettings` is the single owner of the `TestResults` output directory; local `dev.sh coverage` follows that same ownership instead of passing a second results-directory argument. The `ubuntu-24.04` / `net8.0` shards no longer build the test project's unused `net9.0` target; `net9.0` build coverage stays in the Ubuntu compatibility lane. The primary shard also uses `make lint` as the single formatting verifier. The NuGet cache key is based on `packages.lock.json` and `global.json` instead of every project file; locked restore still catches package-input drift, while test-only project edits no longer evict the package cache. The weekly mutation workflow also caches the pinned Stryker global tool and NuGet packages so scheduled mutation runs avoid reinstalling unchanged test tooling. + `CodeIndex.Tests.runsettings` is the single owner of the `TestResults` output directory; local `dev.sh coverage` follows that same ownership instead of passing a second results-directory argument. The `ubuntu-24.04` / `net8.0` shards no longer build the test project's unused `net9.0` target; `net9.0` build coverage stays in the Ubuntu compatibility lane. The primary shard also uses `make lint` as the single formatting verifier. NuGet cache keys fingerprint only the exact `packages.lock.json` files reachable from each workflow's restore graph; `global.json` and unrelated lock files stay outside the identity, exact-hit caches use no broad restore-key fallback, and version-pinned tool caches remain separate. Locked restore still catches package-input drift, while test-only project edits no longer evict the package cache. The weekly mutation workflow also caches the pinned Stryker global tool and NuGet packages so scheduled mutation runs avoid reinstalling unchanged test tooling. + Local `dev.sh mcp-smoke` invokes the just-built `net8.0` DLL from the requested configuration instead of asking `dotnet run` to evaluate and build a second, potentially different configuration. - The C# CodeQL lane only restores and builds; it installs the pinned 9.0 SDK selected by `global.json` without downloading an unused net8 runtime. Runtime test coverage remains in Build/Test and release workflows. -- Keep the CI initial test run and its single retry routed through one workflow helper so logger, blame, and coverage arguments cannot drift. When a PowerShell helper returns the test exit code, keep streamed test output off the function success stream so assignments capture only the numeric exit code. +- Keep the CI initial test run and its single retry routed through one workflow helper so logger, blame, and coverage arguments cannot drift. When a PowerShell helper returns per-run status, keep streamed test output off the function success stream so assignments capture exactly one structured result. - Coverage collection runs only on the initial attempt of each coverage-enabled shard; the one flaky-classification retry reuses the same test arguments without rerunning the coverage collector. - Matrix test invocations use both `--no-build` and `--no-restore` because each lane completes its scoped locked restore and Release build before entering the shared test helper. - Primary-lane publish also uses `--no-build --no-restore`, reusing the production project output and dependency graph built through the Release test project. - Release cross-compile lanes skip the RID-agnostic solution build because they do not run tests and the self-contained RID publish necessarily performs the real build; native lanes retain the solution build before testing. +- Release setup also skips Windows test-host hardening on the non-testing win-arm64 cross-compile lane, caches the pinned CycloneDX tool independently on linux-x64, and gives the fresh `publish-nuget` job a package cache keyed only by the production and package-normalizer lock files. - Release cross-compile lanes likewise use a locked production-project restore instead of restoring test and tool projects they never build; native test lanes retain the locked solution restore. - Release cross-compile lanes install only the repository-selected 9.0 SDK because they publish self-contained binaries and never execute the net8 test host; native lanes retain both pinned SDK lines. - Release workflow tests use `--no-build --no-restore` after the solution's locked restore and Release build so each runtime lane does not reevaluate dependencies. @@ -73,11 +75,12 @@ Use the full suite by default. Use targeted filters only while iterating locally The test project mirrors the production areas closely. Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding `Skip =` cases; it tracks the current split sequence, skip classifications, and large-document boundaries. -- `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` +- `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `DbReaderUtilityTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` Pure or mostly pure behavior tests with in-memory inputs. C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, inherited and qualified field-receiver persistence, extractor-contract reindexing, search declaration metadata, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. Markdown heading-range coverage keeps LF/CRLF and terminated/non-terminated files in one extractor fixture, including empty, one-line, Setext, nested, final-body, empty-body, and large-file cases. Pair it with persisted outline/definition-body and LSP document-symbol assertions so inclusive source ranges and 0-based protocol projection cannot drift apart. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. + `DbReaderUtilityTests` owns reader and SQLite helper contracts that do not depend on the seeded `DbReaderTests` database. Keep static path/query analysis, identifier validation, degradation metadata, and result-shape construction there so each theory row does not initialize and seed a temporary database; move a test back to `DbReaderTests` only when it needs that class's reader, writer, connection, or seeded rows. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths, percent-encoded sensitive keys, or path values with embedded sensitive assignments. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. @@ -103,6 +106,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesContainInContext(...)` when several reference names share the same kind and exact source context; keep direct predicates when context is only one part of a richer edge contract. Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. + JVM reference coverage keeps Kotlin backtick normalization, Java generic-parameter suppression, Kotlin generic pseudo-type suppression, and Java same-line constructor synthesis in four consolidated fixtures. Give each syntax form unique symbol and type-parameter names so another form cannot satisfy its assertions; preserve canonical-name and pseudo-type negatives, real-bound positives and counts, exact constructor-chain counts and `function:` containers, the modifier line, ordinary body-call attribution, and raw `super` / `this` plus declarator self-call exclusions. + Multi-language reference coverage keeps Rust macros, structural declarations, and generic bounds in three fixtures; Swift nested triples in one; Python class headers and annotations in two; COBOL perform/common statements and the target matrix in two; and Shell substitutions and aliases in two. Give each syntax section unique names and preserve line, kind, count, container, symbol, and negative contracts. Keep the COBOL target matrix separate with its exact 42 `reference` plus 4 `call` grouped edges, and place `NEXT-PARA` outside the `PERFORM ... THRU` range. C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side, named-`out`, explicitly typed lambda/anonymous-method, and typed LINQ range-variable type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). C# qualified common-call coverage keeps static BCL, instance, LINQ extension, alias-qualified, current-instance, and unresolved-receiver cases together. Assert that extraction retains every row, default bare-name references/callers/callees and hotspot counts retain resolved evidence while excluding unresolved noise, the completeness option restores that noise deterministically, and dependency queries remain identity-scoped (#4867). C# member-read coverage keeps enum and const patterns, ordinary qualified constants, static readonly fields, static properties, cross-file targets, callable-name collisions, and a true method invocation together. Assert `member_read` extraction without a duplicate `call`, default callers/callees/impact exclusion, explicit compatibility inclusion, and legacy `call` readability in coordinated extractor, full-scan, and `DbReaderTests` fixtures (#4894). @@ -213,6 +218,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding MCP `languages` coverage must additionally keep exact canonical/alias/extension matching (including ambiguity buckets and Unicode-empty lookups), gap-free full enumeration, catalog-generation invalidation, and exact whole-envelope UTF-8 byte boundaries in one focused suite. Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query-, page-size-, and generation-bound cursor across the smallest two-row page boundary. Put inbound callers in another file to prove the location path is only a locator, seed equal-rank same-line callees to pin the complete identity tie-breakers, reject page-size changes before candidate lookup, reject inspect cursors in another command, and verify the same envelopes in MCP `analyze_symbol`. Quiet-flag coverage in `ProgramCliTests.cs` reuses one seeded symbols database across text, NDJSON, and JSON-array modes and compares stdout with and without a trailing quiet alias, proving that quiet mode changes only informational stderr. + Archive-import validation coverage reuses one pristine database export across read-only dry-run and check modes with distinct destinations. Rejection coverage copies one pristine export into manifest-count, database-hash, and user-version variants before mutating any ZIP so one corruption cannot contaminate another. + Archive success-path coverage seeds one metadata-rich database and shares its pristine export across scoped manifest inspection, a default import into a nonexistent destination, a copied legacy-manifest import, and a separate `--no-backup` replacement. Keep the default import and replacement as distinct CLI calls and destinations, and never mutate the pristine archive. Doctor full-inventory coverage keeps composed filter selection, filtered summary counts, exact UTF-8 byte-budget boundaries, and structured overflow errors together in `ProgramRunnerTests`; license JSON remains a subprocess contract in `ProgramCliTests` so immediate-command dispatch and the published field names are both exercised. Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. Dry-run JSON coverage for ambiguous `.h` files locks the bounded `language_detections` entries and their stable source/confidence codes without mutating the index. @@ -227,6 +234,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Symbols compact flag/alias and summary-only JSON envelopes share one editor-format fixture. Symbols JSON array, LSP, quickfix, and SARIF location formats share one editor-format fixture; definition SARIF severity coverage reuses that fixture and asserts informational `note` output separately from warning-level diagnostic output. Validate JSON, compact, count, and SARIF pagination/severity coverage shares one mixed informational/actionable fixture so authoritative totals, limited rows, SARIF levels, actionability metadata, and the count envelope's API version, filter scope, readiness, and legacy total mirror cannot drift across formats; keep missing-`file_issues` count/SARIF degradation and missing-severity-column filtered-count authority coverage in separate legacy-schema fixtures because table and filter availability are distinct mutable states. + Validate limit/top aliases, populated and empty JSON-array views, count-then-JSON precedence, kind filtering and typo hints, and exclusion filters reuse one indexed BOM/mixed/clean superset fixture; create one-row and zero-row views with explicit path scopes instead of rebuilding repositories. Command-specific output format coverage uses a command/format matrix that checks both parser acceptance and the matching usage line; recognized shared formats without a command implementation need a separate usage-error assertion. Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, facet-filtered occurrence-expanded limited, bounded guarded, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts and source-count authority in SARIF result units, applied limits, conservative truncation, null cursor state, raw-FTS and option-like-query replay commands, guard-preserving replay, and unchanged rule/location/severity fields on every run. Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, stable `fingerprints.cdidx/v1` values across identical runs, and the same `query_freshness` run properties as aggregate JSON. Query-freshness coverage must keep successful matched and zero-match executions separate from stale index/recipe/query versions and invalid or missing child executions, preserve the compatibility cardinality fields, and reconcile clean/stale/invalid state counts in mixed runs. Byte-budget coverage must count the complete UTF-8 stdout including JSON escaping and the final newline, exercise exact-fit and one-byte-under boundaries, Unicode, empty and multi-query runs, an individually oversized result, captured/redirected stdout, and replay metadata. Every successful output must parse as complete SARIF, omit only whole results, retain matching rules and locations, and stay within the requested cap. Below-minimum failures must emit no SARIF; non-explicit JSON failures leave stdout empty, while explicit `--json` may emit a bounded versioned error object. Also cover counting-writer measurement and replay recovery when the complete size exceeds the maximum accepted byte cap. @@ -320,6 +328,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Seeded C# foreach shadowing coverage keeps multiline embedded, same-line embedded, and else-branch sources in one database, with path filters preserving per-source scope assertions. Seeded C# lambda-parameter shadowing coverage keeps ordinary, same-line, and parenthesized sources in one database, with path filters preserving per-source scope assertions. Seeded C# declaration-pattern statement coverage keeps ordinary if, multiline if, and multiline while sources in one database, with path filters preserving exact line assertions. + SQL ALTER-object target coverage keeps the 11 VIEW / PROCEDURE / FUNCTION / TRIGGER / SEQUENCE / SECURITY POLICY / FULLTEXT CATALOG / PARTITION FUNCTION / PARTITION SCHEME / XML SCHEMA COLLECTION / ASSEMBLY forms in one extraction pass and asserts all 22 target/line pairs. SQL ON-target coverage keeps its 14 regex scenarios in a second pass, uses a distinct table leaf per scenario, preserves multiline security-policy predicate lines and the ALTER policy-name reference, and retains every call / keyword / object-name negative. Production-runtime SQL line-end comment coverage keeps multiline sources, unfinished prefixes, and unfinished target prefixes in one indexed workspace, with path filters preserving every reference count and kind. Production-runtime SQL `USING` / `MERGE` coverage keeps DELETE sources, DDL matcher controls, target hints, and temporary targets in one indexed workspace, with path filters preserving qualified-name and negative contracts. Production-runtime SQL semicolonless temporary-table coverage keeps SET / DECLARE and IF / WHILE sources in one indexed workspace, with path filters preserving established and future-read contracts. @@ -330,16 +339,18 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Apply the same combined null-comparison fixture to inspect reference-bundle coverage instead of indexing each operator separately. Production-runtime switch relational-pattern coverage places less-than and greater-than methods in one source and pays one CLI indexing subprocess. Generic switch-arm guard and relational predecessors likewise share one production-runtime fixture and run only on the production `net8.0` target. - Search language-alias coverage may place distinct language files in one database and iterate alias filters when each filter isolates one expected result. + Search language-alias coverage keeps one indexed file per canonical XML, Rust, C#/Razor, Java, Kotlin, JavaScript, YAML, batch, SQL, Ruby, and F# language in one database; shared query tokens plus exact result-path assertions preserve cross-language alias-to-canonical isolation, distinct spelling/casing aliases are iterated once, and Ruby/F# retain exact-search coverage. + Language-alias catalog coverage queries each canonical language once and iterates its expected aliases in one fact so adding a language does not multiply identical discovery and assertion setup. + Swift, Objective-C, Gradle, Terraform, PowerShell, and Batch production reference coverage keeps one extraction fixture per language. Each fixture combines its positive syntax families with disjoint definition, assignment, comment, string, operator, or label controls and asserts kind, line, context, and enclosing container; keep identities such as `unused_region`, `Ignored-Command`, and `DeclaredOnly` distinct from positive references. + Unfiltered `languages --json` catalog coverage invokes the command once, builds one canonical-language dictionary, and keeps extension, alias, extraction, graph, gap, guidance, and exact-filename contracts together so expanding language coverage does not repeat catalog discovery and serialization. Named-query escaping for option-looking literals reuses one indexed Probe fixture across definition, graph, symbols, files, inspect, and impact commands. Multi named-query output coverage reuses one indexed fixture for compact projection, rich JSON compatibility, per-query limits/truncation, and UTF-8 byte caps so the serializer modes stay directly comparable. Shared bounded-response coverage reuses one graph-ready database across definition, find, status, hotspots, references, callers/callees, impact, and map; keep cursor and UTF-8 byte-budget boundary cases in a separate minimal multi-row fixture so family parity does not multiply indexing setup. Outline keeps one focused deep-hierarchy fixture with long signatures and Unicode to verify exact newline-inclusive byte boundaries, full cursor walks without gaps or duplicates, minimum-budget diagnostics, and unchanged uncapped output. Regression coverage must also exercise aliases and read-only batch dispatch, explicit definition body projections, inactive impact collections, and row-wise map-section pagination with authoritative totals. Adversarial bounded-response coverage must also lock parser-failure byte caps, impact definition-page offsets, legacy map compact sections, conflicting map shape controls, compact explicit bodies, and profile/verbose control-record extraction. Response-budget preflight coverage must assert parseable stdout and empty stderr for zero and tiny budgets, duplicate and multi-error option parsing, NDJSON terminal and first-results-only-row preflight, exact-minimum retry for stable map/recipe payloads, explicit uncertainty plus recommended headroom for runtime envelopes, size-reduction guidance above the effective maximum, empty and non-empty rows, Unicode/escaping, and the invariant that no normal payload exceeds its requested UTF-8 cap. - Search alias variants for JavaScript extensions, YAML, batch, and SQL dialects each reuse one language fixture and iterate casing/spelling forms in a fact. + Unused JSON byte-budget coverage shares one Unicode graph-ready fixture across legacy canonical, envelope, compact, by-bucket, empty, minimum, and cursor-binding cases. Run every read-only paging and boundary assertion before the index-generation mutation that proves stale-cursor rejection; oversized search response coverage serializes its greater-than-16-MiB payload once and iterates requested/effective budget pairs. Raw FTS syntax coverage reuses one indexed source for a valid control query and all invalid query/hint variants. Literal and raw FTS complexity bounds reuse one indexed source across length, token-count, NEAR-count, and lowercase-operator controls. - XAML, Rust, common multi-language, and JavaScript alias sets each build their fixture once and iterate all accepted spellings and casing forms. Inline comment-marker exclusion places JavaScript line/block and Python line comments in one index and iterates marker queries. Search exact-mode conflict coverage shares one empty database for all pairwise and triple flag sets. Search path and exclude-path invalid-glob guards share one empty database and iterate option names before query evaluation. @@ -369,6 +380,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `ReadDockerfile()` and `ReadDockerIgnore()` for release-container contract tests so canonical fixture paths do not drift across workflow suites. `RepositoryTestPaths` caches checked-in text, normalized derived text, and normalized workflow inventories for the lifetime of the test process. Keep it for immutable repository contracts only; tests that rewrite fixtures must use their own temporary paths. License-policy contract tests use the same accessor for legal notices, workflow files, and distribution docs instead of rediscovering the repository root and rereading overlapping files. + License-policy workflow path filters mirror only the files read by its shell validation and filtered `LicensePolicyTests` run. Documentation and test sources read by neither do not start this focused job. Generated `install.sh` and installer/release test sources remain owned by the full Build/Test workflow. Repository-backed documentation, source-audit, JSONL-policy, and trimmed-publish tests reuse `RepositoryTestPaths.Root` instead of maintaining suite-local upward directory walks. Large command-runner, installer, and extractor suites also delegate their legacy root helpers to that single cached root. Changelog limit tests resolve checked-in files through `RepositoryTestPaths` instead of performing another root walk. @@ -459,8 +471,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding are broad runaway guards for known large symbol-extraction fixtures. Keep their budgets generous enough for full-suite load; tighten them only with focused optimization evidence, not as benchmark thresholds. - `SymbolExtractorTests.Extract_CSharp_LargeSwitchExpression_CompletesWithinPracticalBudget` keeps 10,000 switch arms plus functional symbol assertions and uses a broad 15-second runaway budget. Treat it as a quadratic-regression tripwire rather than a benchmark threshold; the margin must absorb noisy full-suite hosts (#4792). -- `ReferenceExtractorTests.Extract_CSharpLargePlainCallFile_CompletesWithinPracticalBudget` - is a broad runaway guard for high-volume C# reference extraction on ordinary call lines. Treat its budget as a regression tripwire, not a benchmark target; keep it wide enough for noisy CI unless a focused optimization change justifies tightening it. +- `ReferenceExtractorPerformanceBudgetTests.Extract_CSharpLargePlainCallFile_CompletesWithinPracticalBudget` and `Extract_CSharpLargeMethodWithManyLocals_CompletesWithinPracticalBudget` + are broad runaway guards for high-volume C# reference extraction. Their C# warmup runs only under `CI=true` in the `net8.0` test assembly, once per test process through a `Lazy` gate invoked by the guards before fixture construction and stopwatch measurement; shards without either guard pay no extraction or forced-GC warmup cost. Keep the module initializer's hook-discovery delay, persistent worker PID/thread, and persistent descendant PID/process environment handling eager and in that order. Because the warmup ends with forced GC, keep only these two guards in their dedicated non-parallel collection rather than making the large `ReferenceExtractorTests` partial class non-parallel. Treat their budgets as regression tripwires, not benchmark targets; keep them wide enough for noisy CI unless a focused optimization change justifies tightening them. - Reference-extraction cap coverage keeps the four published boundaries in one small `ReferenceExtractorTests` fixture using test-only limits, and keeps the full persistence/status path in one `IndexCommandRunnerTests` fixture. Graph @@ -643,7 +655,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding GitHub token resolution logic (CDIDX_GITHUB_TOKEN only; generic GITHUB_TOKEN is ignored), outbound code scrubbing, idempotency checks, and rate-limit diagnostics. - `PackagesLockTests.cs` NuGet lock-file guard coverage for direct package references that must remain synchronized across all target frameworks, including the net9.0 compatibility references that keep locked CI restore green. - Workflow restore-contract coverage keeps the release native lanes' locked `net8.0` test-project restore distinct from the cross-compile lanes' locked production-project restore while retaining exact cache keys on every surface. + Workflow restore-contract coverage fingerprints only lock files reachable from each restore. Build/Test, CodeQL, license-policy, mutation, and native release lanes share the explicit six-lock solution/test graph, while cross-compile release lanes use only `src/CodeIndex/packages.lock.json`; curated release notes and NuGet normalization retain their one-lock and two-lock graphs. Keep `global.json` and the unrelated `examples/hooks` lock outside NuGet cache identities, use exact-hit caches without `restore-keys`, and keep the version-pinned Stryker tool cache independent from the mutation job's NuGet cache. - `ConcurrencyTests.cs` Concurrent read and read-during-write scenarios (WAL mode validation), including the issue #180 bug-catching snapshot-isolation regressions for all three multi-statement reader entry points: (1) `GetStatus` seeds `refs == files * refsPerFile` and asserts every concurrent observation preserves that invariant; (2) `AnalyzeSymbol` seeds one symbol `S` plus matching reference/caller pairs, toggles a second file symmetrically, and asserts `references.Count == callers.Count` across every `inspect`/`analyze_symbol` bundle; (3) `GetRepoMap` seeds a baseline modified timestamp and toggles a newer file, asserting `latest_modified == workspace_latest_modified` across every map call. Each test fails without the DEFERRED-transaction wrap on the matching reader and passes with it. - `PerformanceTests.cs` @@ -680,6 +692,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Regression coverage for the maintained per-file hotspot aggregate, including legacy transactional backfill, high-cardinality limited file/name queries, aggregate/raw logical-site parity, cross-file context and identity invalidation, and cancellation after aggregate SQL begins. Query-plan assertions require the rank index on `hotspot_reference_counts`, reject raw `symbol_references` scans, and use the shared broad deterministic timeout instead of a benchmark-grade threshold. - `.github/scripts/run-dotnet-tests.ps1` The `dotnet.yml` matrix test step delegates test argument construction, coverage gating, `TestResults` path ownership for failure-log capture, TestSessionTimeout handling, and single flaky retry classification to this script. Keep workflow YAML limited to matrix/lane parameter wiring, and update `CiWorkflowTests` when changing either the script contract or artifact/summarize gating. + Stream the complete `dotnet test` output to the step log, but retain only the final 2,000 lines for a failed-attempt artifact. Prefix a truncated artifact with retained, total, and omitted line counts. Detect the case-insensitive `test run timeout` marker while streaming and return exactly one structured result containing `ExitCode` and `TestSessionTimedOut` from both the initial and retry attempts; do not rescan the artifact. Keep the shared runsettings test-session timeout at 75 minutes, below the workflow's 90-minute job timeout. This gives the slower Windows lane enough time to complete while preserving a bounded failure and post-test cleanup window. Keep `TreatNoTestsAsError` enabled in the shared runsettings. A zero-match initial run or retry must fail rather than turning an earlier test failure green without executing any tests. Keep the converted coverage boolean in a local whose name differs from the case-insensitive `CollectCoverage` string parameter; otherwise PowerShell coerces the boolean back to a string before invoking typed helpers. @@ -688,6 +701,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding After an ordinary assertion failure, the helper uses the already-built `CodeIndex.TestTelemetry retry-filter` command to derive a bounded VSTest `FullyQualifiedName` filter from `test_results_first.trx`. Use the focused retry only for a complete, internally consistent failed TRX in which every failed `testId` maps unambiguously to a filter-safe test method; cap it at 20 failed results and 4,096 filter characters. Missing, unreadable, oversized, malformed, aborted or incomplete, run-level host/adapter/data-collector errors, inconsistent, ambiguous, unsafe, over-count, and over-length inputs fall back once to the full suite on unfiltered lanes or the current full shard on filtered lanes. Ordinary xUnit skip warnings remain eligible, and an xUnit `RunInfo outcome="Error"` remains eligible only when its exact `[FAIL]` display name matches an actual failed result; malformed or uncorrelated errors still force the corresponding full-suite or full-shard fallback. `TestSessionTimeout` still skips retry entirely. Both retry scopes keep a separate retry TRX and blame-hang evidence, while coverage and crash collection remain initial-attempt only; a passing retry still writes `flaky-retry.txt`, including the retry scope. Collect crash diagnostics on the initial attempt only. The flaky-classification retry reuses that evidence and skips the crash collector, while retaining blame-hang and its five-minute kill bound in case the retry hangs. Summarize TRX telemetry only when the test helper reports a failed initial attempt (including pass-on-retry); clean first-pass lanes and jobs that failed before testing should not pay for a second process launch and TRX parse. Invoke the already-built telemetry DLL directly for retry-filter and summary operations so failure handling does not re-evaluate its project through `dotnet run`. Keep result, dump, and coverage artifact uploads gated on the test step having started, so restore/build failures do not launch empty artifact actions. + Materialize the primary `dotnet publish` output and `CodeIndex` build artifact only for main-branch pushes or manual dispatches, not pull requests. Start the coverage upload only when `TestResults/**/coverage.cobertura.xml` exists, and exclude that file from the failure-oriented `TestResults` artifact so coverage is stored once while TRX, text logs, and other XML blame evidence remain available. - `.github/scripts/configure-windows-test-host.ps1` The `dotnet.yml` and `release.yml` Windows lanes share temp pinning and Defender exclusion setup here so both workflows keep the same test-host performance assumptions. General `TMP` / `TEMP` point to the runner's fast `RUNNER_TEMP\cdidx-temp` storage. Executable plugin, hook, and Git fixtures instead use `USERPROFILE\cdidx-trusted-test-temp`, whose protected current-user ACL and trusted ancestor chain satisfy the production executable-boundary contract; the script publishes this separate root as `CDIDX_TEST_TRUSTED_TEMP_ROOT`. Do not move ordinary SQLite or filesystem fixtures into that protected root, because placing the entire suite on the system drive materially increases Windows runtime. The script includes both roots in its normalized, de-duplicated Defender audit, submits the resulting string array in one `Add-MpPreference` invocation, and then reads Defender preferences back and fails if any path is missing. Update `CiWorkflowTests` when changing this split, batching, audit, verification, or workflow call contract. - The `dotnet.yml` SDK setup has one conditional retry for transient SDK download failures. Keep the first attempt marked `continue-on-error` only while the retry is guarded by its failed outcome, so a second failure still fails the job. @@ -788,7 +802,8 @@ Use the inventory below before adding or moving a test class: - Workspace metadata result-shape parity should enrich status, map, and analysis objects from one dirty Git fixture instead of initializing and committing three identical repositories. - Persisted-HEAD drift and recovery assertions should update metadata within one Git fixture rather than creating a second repository merely to test the matching state. - Latest-indexed-HEAD precedence should be asserted for status and analysis result shapes from one seeded repository rather than duplicating identical Git and database setup. -- Commits-ahead ancestor and missing-stamp behavior should share the same multi-commit repository; the missing case only requires a fresh result object without `IndexedHeadSha`. +- Ordinary `GitHelper` HEAD metadata coverage should reuse one repository across unborn, resolved root/subdirectory, named-branch, and detached-HEAD assertions; detach only after every branch assertion, and keep non-repository, bare, corrupt-metadata, timeout, and cancellation paths separate. Commits-ahead equal, linear, divergent, and invalid-base results should share one repository whose fixture-owned main branch receives two empty commits after a sibling branch diverges from the indexed base. Repository `core.ignorecase` true/false coverage should reuse one init-only repository and subdirectory, changing the config between assertions. Use `--allow-empty` when only commit topology is under test. +- Commits-ahead ancestor and missing-stamp behavior in command-runner result shapes should share the same multi-commit repository; the missing case only requires a fresh result object without `IndexedHeadSha`. - Shared file-URI escaping and LSP round-trip parity should use one path/root case rather than duplicating equivalent percent-encoding setup in separate tests. - Ordinary ad-hoc issue-draft replay coverage should seed one 126-row fixture and compare the original and replayed selection plus metadata in one test; parse the emitted restricted POSIX quoting in-process instead of launching a platform-specific shell. Keep the broad guarded-search safety regression separate because it crosses the candidate cap and verifies lower-bound source metadata; use one indexed file with sentinel chunks rather than hundreds of files. - No-timeout sentinel coverage should exercise zero and infinite budgets in one contract test; both follow the same caller-cancellation path and do not need duplicate scope setup. @@ -1008,16 +1023,18 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - incremental TypeScript augmentation coverage は、full scan、scoped update、MCP indexing を横断して変更前・変更後の interface 名を追跡します。その完全一致名を共有する宣言だけを再構築し、削除済み名の stale augmentation 行を消し、SQLite parameter budget 未満で name predicate をbatch化してください。fresh/rebuild、広範なdirty-name集合、project rootまたはcontract version変更、開始時点での強制extractor refreshでは全量fallbackを維持します。実行中に判明するJavaScript/TypeScript設定refreshは、refresh対象全fileの変更前後の名前を追跡し、広範囲集合ではadaptive fallbackをauthoritativeにします。full-fallbackとsymbols-only pathではinterface名をmaterializeせず、rollback-safeなreadiness-only trackingを維持します。1,000個の未変更singleton interfaceがcandidate外であること、1,001 requested namesでname batch境界を越えること、5,001-name requestが全量pathへ戻ること、無関係なmerged referenceとdisk fallback不能時に非dirty名が示すmodule分類を維持すること、stale-file purgeおよび3つのindexing pathすべてで置換前に永続化済みTypeScript→非TypeScript言語遷移を検知すること、直近のrollback後にreadinessを最大1回clearしそのclearが永続化している間は追加checkを省くこと、cancel時に同期SQLite処理をinterruptしてaugmentation行をrollbackすることをcoverageに含めます。 - index-finalization readiness coverage は active writer transaction 中の reference-cap read、IssuesReady flag を欠いた scoped update が unavailable な last-run cap snapshot を維持すること、C# / VB の partial hotspot-family rows を1回の grouped reader initialization で検証します。readiness SQL を変更するときは両言語の結果と degraded-readiness gate を維持し、wall-clock threshold へ置き換えないでください。 - CI は `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings` 経由でテストプロジェクトを実行し、VSTest の blame crash / hang 収集、75分のセッションタイムアウト、60秒の xUnit long-running 診断を有効にします。初回失敗時は suite を1回だけ再実行し、再実行で成功した場合は TRX / blame artifact と一緒に `TestResults/flaky-retry.txt` を upload して、その実行を疑わしい flaky run として扱います。 - TRX telemetry summary と test-result artifact upload は失敗または retry 成功 lane だけで実行し、初回で clean に成功した lane では実行しません。stream された test output も、失敗後に upload / timeout inspection が必要な場合だけ `TestResults` 配下へ書き、failure log directory もその failure path でだけ作成します。telemetry summarizer と retry-filter command は build 済みの Release helper を直接起動するため、failure diagnostics で restore や build evaluation を繰り返しません。 + TRX telemetry summary と test-result artifact upload は失敗または retry 成功 lane だけで実行し、初回で clean に成功した lane では実行しません。test output 全体は step log へ stream し続け、失敗 attempt だけが末尾の上限付き tail を `TestResults` 配下へ書きます。切り詰めた artifact の先頭には retained / total / omitted 行数を明記し、failure log directory は従来どおり failure path でだけ作成します。TestSessionTimeout は stream 中に検出するため、保持 tail に依存しません。telemetry summarizer と retry-filter command は build 済みの Release helper を直接起動するため、failure diagnostics で restore や build evaluation を繰り返しません。 XPlat Code Coverage の収集は `ubuntu-24.04` / `net8.0` shard に限定します。この coverage shard と Windows / macOS の `net8.0` は、`IndexCommandRunnerTests` とその補集合を別 process の補完的な2 shardに分けます。各 filter pair で suite 全体を保ちながら wall-clock time を短縮し、Ubuntu の `net9.0` compatibility lane は1つの full-suite process のまま維持します。初回実行と full fallback は lane filter を維持し、focused retry は failed-test filter と交差させ、test artifact 名には shard identity を含めます。 OS coverage は production CLI target の `net8.0` で実行し、`net9.0` compatibility coverage は `ubuntu-24.04` のみに絞ります。テスト実行は locked restore と Release build の後に `--no-build` で走らせます。primary Ubuntu coverage shard は audit / publish coverage のため solution 全体を restore し、その後 `tests/CodeIndex.Tests/CodeIndex.Tests.csproj` を matrix framework 向けに build します。non-primary lane は同じ per-framework build の前に、`RestoreTargetFrameworks` でその test project の matrix framework だけを restore します。`net8.0` lane は、`global.json` が選ぶ 9.0 SDK で project を build し、8.0 SDK が test runtime を供給するため、両方の pinned SDK を維持します。`net9.0` compatibility lane は `9.0.301` だけを導入し、未使用の8.0 SDK/runtime downloadを避けます。 - `TestResults` 出力ディレクトリは `CodeIndex.Tests.runsettings` だけが管理します。ローカルの `dev.sh coverage` も同じ所有関係に従い、2 つ目の results-directory 引数は渡しません。`ubuntu-24.04` / `net8.0` shard では test project の未使用 `net9.0` target を build しません。`net9.0` build coverage は Ubuntu compatibility lane で維持します。primary shard の formatting verifier は `make lint` だけを使います。NuGet cache key は全 project file ではなく `packages.lock.json` と `global.json` に基づきます。package 入力の drift は locked restore で検出しつつ、テスト用 project だけの変更では package cache を失効させません。weekly mutation workflow も pinned Stryker global tool と NuGet package を cache し、変更のない test tooling を scheduled mutation run で再インストールしないようにします。 + `TestResults` 出力ディレクトリは `CodeIndex.Tests.runsettings` だけが管理します。ローカルの `dev.sh coverage` も同じ所有関係に従い、2 つ目の results-directory 引数は渡しません。`ubuntu-24.04` / `net8.0` shard では test project の未使用 `net9.0` target を build しません。`net9.0` build coverage は Ubuntu compatibility lane で維持します。primary shard の formatting verifier は `make lint` だけを使います。NuGet cache key は各 workflow の restore graph から到達可能な `packages.lock.json` だけを fingerprint にし、`global.json` と無関係な lock file を identity から除外します。exact-hit cache は広い restore-key fallback を使わず、version 固定 tool cache は NuGet cache と分離します。package 入力の drift は locked restore で検出しつつ、テスト用 project だけの変更では package cache を失効させません。weekly mutation workflow も pinned Stryker global tool と NuGet package を cache し、変更のない test tooling を scheduled mutation run で再インストールしないようにします。 + ローカルの `dev.sh mcp-smoke` は `dotnet run` に2つ目の異なる可能性があるconfigurationを評価・buildさせず、要求されたconfigurationでbuild直後の `net8.0` DLLを起動してください。 - C# CodeQL lane は restore と build だけを行うため、`global.json` が選ぶ pinned 9.0 SDK だけを導入し、未使用の net8 runtime を download しません。runtime test coverage は Build/Test と release workflow で維持します。 -- CI の初回テスト実行と1回だけの retry は同じ workflow helper 経由にし、logger、blame、coverage 引数が drift しないようにしてください。PowerShell helper がテストの exit code を返す場合は、stream された test output を関数の success stream に載せず、代入で数値の exit code だけを受け取れるようにします。 +- CI の初回テスト実行と1回だけの retry は同じ workflow helper 経由にし、logger、blame、coverage 引数が drift しないようにしてください。PowerShell helper が attempt ごとの status を返す場合は、stream された test output を関数の success stream に載せず、代入で単一の構造化結果だけを受け取れるようにします。 - coverage collection は coverage が有効な各 shard の初回 test attempt だけで実行し、flaky classification の1回だけの retry では同じ test 引数を再利用しつつ coverage collector を再実行しないでください。 - matrix test invocation は shared test helper の前に各 lane の scoped locked restore と Release build が完了しているため、`--no-build` と `--no-restore` の両方を使ってください。 - primary-lane publish も `--no-build --no-restore` を使い、Release test project 経由で build 済みの production project output と dependency graph を再利用してください。 - release の cross-compile lane は test を実行せず、self-contained RID publish が実 build を必ず行うため、RID 非依存の solution build を省略する。native lane は test 前の solution build を維持する。 +- release setupでは、testを実行しないwin-arm64 cross-compile laneのWindows test-host hardeningも省略し、linux-x64では固定CycloneDX toolを独立cacheし、freshな`publish-nuget` jobにはproduction / package-normalizer lock fileだけをkeyにしたpackage cacheを持たせてください。 - release の cross-compile lane は build しない test / tool project を復元せず、production project だけを locked restore する。native test lane は locked solution restore を維持する。 - release の cross-compile lane は self-contained binary を publish し、net8 test host を実行しないため、repository が選択する9.0 SDK だけを install する。native lane はpinされた両 SDK lineを維持する。 - release workflow の test も solution の locked restore と Release build 後に `--no-build --no-restore` を使い、runtime lane ごとの dependency 再評価を避けてください。 @@ -1035,11 +1052,12 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" テストプロジェクトは、本番コードの責務にかなり近い形で分かれています。 巨大 suite を移動する場合や `Skip =` case を追加する場合は、現在の分割順序、skip 分類、巨大ドキュメントの境界を追跡する `docs/test-doc-maintenance-plan.md` を先に確認してください。 -- `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` +- `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`DbReaderUtilityTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、継承 / qualified field receiver の永続化、extractor contract による再 index、search declaration metadata、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 Markdown heading range の coverage は LF / CRLF と終端改行あり / なしを1つの extractor fixture にまとめ、empty、1行、Setext、nested、final body、empty body、large file の case を含めます。包含 source range と0始まりの protocol 投影がずれないよう、永続化後の outline / definition body と LSP document symbol の assertion を対にしてください。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 + `DbReaderUtilityTests` には、seed 済み `DbReaderTests` database に依存しない reader / SQLite helper 契約を置きます。各 theory row が一時 database の初期化と seed を繰り返さないよう、static な path / query analysis、identifier validation、degradation metadata、result-shape construction はこの軽量 class に保ち、reader、writer、connection、または seed row が必要になった場合だけ `DbReaderTests` へ戻してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path、percent-encoded な機密 key、機密 assignment を内包する path 値を持つ file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 @@ -1066,6 +1084,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 複数の reference name が同じ kind と完全一致 source context を共有する場合は `AssertReferencesContainInContext(...)` を使い、context がより詳細な edge contract の一部にすぎない場合は直接 predicate を維持します。 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 + JVM reference coverage は、Kotlin backtick 正規化、Java generic parameter 抑止、Kotlin generic pseudo-type 抑止、Java same-line constructor 合成を4つの統合 fixture に維持します。別形式の正例で assertion が通らないよう各構文形式に固有の symbol / type-parameter 名を与え、canonical 名と pseudo-type の負例、実 bound の正例と件数、constructor-chain の厳密件数と `function:` container、modifier 行、通常 body call の帰属、raw `super` / `this` と declarator self-call の除外を保持してください。 + 多言語 reference coverage は、Rust macro / structural declaration / generic bound を3 fixture、Swift nested triple を1 fixture、Python class header / annotation を2 fixture、COBOL perform/common statement と target matrix を2 fixture、Shell substitution / alias を2 fixture に維持します。各構文区画に固有名を与え、line / kind / count / container / symbol / negative 契約を保持してください。COBOL target matrix は厳密な42 `reference` + 4 `call` のgrouped edgeを持つ独立fixtureのままにし、`NEXT-PARA` は `PERFORM ... THRU` range の外へ配置してください。 C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側、named `out` declaration、明示型 lambda / anonymous method、および型付き LINQ range variable の type reference、property subpattern の型、ならびに alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 C# の修飾付き一般名 call の coverage は、static BCL、instance、LINQ extension、alias 修飾、current instance、未解決 receiver の各 case を同じ fixture に維持します。extraction が全 row を保持すること、無修飾名による references / callers / callees と hotspot count の既定動作が解決済み evidence を維持しつつ未解決 noise を除外すること、completeness option がその noise を決定的に復元すること、dependency query が identity scope のままであることを検証してください(#4867)。 C# member-read coverage は enum / const pattern、通常の修飾付き定数、static readonly field、static property、cross-file target、callable 名の衝突、真の method invocation を同じ fixture に維持します。連携する extractor / full-scan / `DbReaderTests` fixture で、重複 `call` を伴わない `member_read` 抽出、既定 callers / callees / impact からの除外、明示 compatibility option による復元、legacy `call` row の読み取りを検証してください(#4894)。 @@ -1179,6 +1199,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" MCP `languages` の coverage ではさらに、canonical / alias / extension の完全一致(ambiguity bucket と Unicode の空 lookup を含む)、欠落のない全件列挙、catalog generation の失効、response envelope 全体の UTF-8 byte exact boundary を1つの focused suite にまとめます。 inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。inbound caller は別ファイルに置いて location path が locator にすぎないことを証明し、最小の2行 page 境界で query / page size / generation に束縛された cursor を再利用します。同順位かつ同じ行の callee で完全な identity tie-breaker を固定し、candidate lookup より前に page-size 変更を拒否し、別 command では inspect cursor を拒否してください。MCP `analyze_symbol` でも同じ envelope を確認します。 `ProgramCliTests.cs` の quiet flag coverage は1つの seeded symbols database を text、NDJSON、JSON array の各 mode で再利用し、末尾に quiet alias を付けた場合と付けない場合の stdout を比較して、quiet mode が informational stderr だけを変えることを固定します。 + archive import validation coverage は、read-only な dry-run / check mode で1つの pristine database export を別々の destination から共有してください。拒否 coverage では、mutation 前に1つの pristine export を manifest-count、database-hash、user-version 用の3つの ZIP へコピーし、ある corruption が別 case を汚染しないようにします。 + archive success-path coverage は、metadata-rich な database を1回 seed し、その pristine export を scoped manifest inspection、存在しない destination への default import、コピーした legacy manifest の import、別 destination への `--no-backup` replacement で共有します。default import と replacement は別々の CLI 呼び出しと destination に保ち、pristine archive を直接変更しないでください。 doctor full-inventory coverage では、合成 filter の選択、filtered summary 件数、UTF-8 byte budget の exact boundary、structured overflow error を `ProgramRunnerTests` にまとめます。license JSON は `ProgramCliTests` の subprocess contract として、immediate-command dispatch と公開 field 名を同時に検証します。 ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 曖昧な `.h` に対する dry-run JSON coverage は、index を変更せず、上限付き `language_detections` entry と安定した判定元・信頼度 code を固定します。 @@ -1193,6 +1215,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" symbols compact flag/aliasとsummary-only JSON envelopeは1つのeditor-format fixtureを共有してください。 symbols JSON array、LSP、quickfix、SARIF location format は1つの editor-format fixture を共有し、definition SARIF severity のテストも同じ fixture を再利用して、情報レベルの `note` 出力を warning レベルの診断出力とは分けて検証してください。 validate の JSON、compact、count、SARIF における pagination / severity coverage は、informational finding と actionable finding が混在する1つの fixture を共有し、authoritative な総件数、limited row、SARIF level、actionability metadata、および count envelope の API version、filter scope、readiness、legacy total mirror が format 間で drift しないことを検証してください。`file_issues` 欠落時の count / SARIF degradation coverage と severity column 欠落時の filtered count authority coverage は、table と filter の availability が別々の mutable state なので、独立した legacy-schema fixture に分けてください。 + validate の limit/top alias、issueあり/emptyのJSON-array view、count後のJSON precedence、kind filter/typo hint、exclude filterは、BOM/mixed/cleanを含む1つのindexed superset fixtureを共有してください。repositoryを再構築せず、明示的なpath scopeで1件/0件のviewを作ります。 コマンド別の出力形式 coverage は command / format matrix で parser の受理と対応する usage line の両方を検証してください。共通 parser が認識してもコマンド側に実装がない形式には、別途 usage error の assertion が必要です。 ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、facet filter 付き occurrence 展開後の limited、bounded guard、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count と source count の確定性、適用済み limit、保守的な truncation、null cursor state、raw FTS と option のような query の replay command、guard を保持する replay、および各 run で rule / location / severity field が不変であることを検証してください。 Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1`、aggregate JSON と同じ `query_freshness` run properties を検証してください。query freshness coverage では、成功した matched / zero-match execution を stale な index / recipe / query version および invalid / missing child execution と分離し、互換用の件数フィールドを維持し、mixed run の clean / stale / invalid state count が整合することを検証してください。byte-budget coverage では JSON escape と末尾改行を含む完全な UTF-8 stdout を数え、exact-fit と1 byte不足の境界、Unicode、空 run と複数 query の run、単体で oversized な result、capture / redirect した stdout、replay metadata を扱ってください。成功した出力はすべて完全な SARIF として parse でき、result を1件単位でのみ省略し、対応する rule / location を維持し、要求 cap 以下でなければなりません。最小値未満の失敗では SARIF を出力せず、明示 JSON でない失敗は stdout を空にし、明示的な `--json` では上限内の version 付き error object を出力できることも検証してください。counting writer による計測と、完全な size が受理可能な最大 byte cap を超える場合の replay recovery も扱ってください。 @@ -1285,6 +1308,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" seed済みC# foreach shadowing coverageはmultiline embedded、same-line embedded、else-branch sourceを1つのdatabaseで共有し、path filterでsourceごとのscope assertionを維持してください。 seed済みC# lambda-parameter shadowing coverageはordinary、same-line、parenthesized sourceを1つのdatabaseで共有し、path filterでsourceごとのscope assertionを維持してください。 seed済みC# declaration-pattern statement coverageはordinary if、multiline if、multiline while sourceを1つのdatabaseで共有し、path filterでexact line assertionを維持してください。 + SQL ALTER-object target coverageはVIEW / PROCEDURE / FUNCTION / TRIGGER / SEQUENCE / SECURITY POLICY / FULLTEXT CATALOG / PARTITION FUNCTION / PARTITION SCHEME / XML SCHEMA COLLECTION / ASSEMBLYの11形式を1回のextraction passで共有し、22組すべてのtarget / lineを検証してください。SQL ON-target coverageは14個のregex scenarioを2回目のpassで共有し、scenarioごとに異なるtable leafを使い、複数行security-policy predicateの行、ALTER policy名のreference、すべてのcall / keyword / object-name negativeを維持してください。 production-runtime SQL line-end comment coverageはmultiline source、unfinished prefix、unfinished target prefixを1つのindexed workspaceで共有し、path filterで全reference count / kindを維持してください。 production-runtime SQL `USING` / `MERGE` coverageはDELETE source、DDL matcher control、target hint、temporary targetを1つのindexed workspaceで共有し、path filterでqualified-name / negative contractを維持してください。 production-runtime SQL semicolonless temporary-table coverageはSET / DECLAREとIF / WHILE sourceを1つのindexed workspaceで共有し、path filterでestablished / future-read contractを維持してください。 @@ -1295,17 +1319,19 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" inspect reference-bundle coverageにも同じnull-comparison統合fixtureを適用し、operatorごとの個別indexingを避けてください。 production-runtime switch relational-pattern coverage はless-thanとgreater-thanのmethodを1 sourceに置き、CLI indexing subprocessを1回だけ実行してください。 generic switch-arm のguardとrelational predecessorも同様に1つのproduction-runtime fixtureを共有し、production `net8.0` targetだけで実行してください。 - search language-alias coverage は、各filterが期待結果を1件に分離できる場合、異なる言語fileを1 databaseに置いてalias filterを反復してください。 + search language-alias coverage は、canonical XML、Rust、C#/Razor、Java、Kotlin、JavaScript、YAML、batch、SQL、Ruby、F# ごとに1つのindexed fileを1 databaseで共有してください。shared query tokenと厳密なresult path assertionでcross-languageのalias-to-canonical isolationを維持し、異なるspelling/casing aliasは1回だけ反復し、Ruby/F#のexact-search coverageも保持してください。 + language-alias catalog coverageはcanonical languageごとに1回だけqueryし、期待aliasを1つのfact内で反復してください。言語追加のたびに同一のdiscovery / assertion setupを増やさないようにします。 + Swift、Objective-C、Gradle、Terraform、PowerShell、Batchのproduction reference coverageは、言語ごとに1つのextraction fixtureを共有してください。positiveな構文familyと、名前が衝突しないdefinition、assignment、comment、string、operator、label controlをまとめ、kind、line、context、enclosing containerを検証します。`unused_region`、`Ignored-Command`、`DeclaredOnly`のようなnegative identityはpositive referenceと分離してください。 + filterなしの`languages --json` catalog coverageはcommandを1回だけ実行し、canonical language辞書を1つ構築して、extension、alias、extraction、graph、gap、guidance、exact-filenameの各contractをまとめて検証してください。言語coverageの拡張でcatalog discoveryとserializationを繰り返さないようにします。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller と一意でない resolved overload group を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。outline は、長い signature と Unicode を含む深い階層の focused fixture 1つを使い、最後の改行を含む正確な byte 境界、欠落や重複のない cursor 全 page 走査、最小 budget の diagnostic、上限なし出力の非変更を確認してください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 response-budget preflight coverage では、0 / tiny budget で stdout が解析可能かつ stderr が空であること、重複 option と複数 error の parse、NDJSON terminal と results-only の先頭 row の preflight、安定した map / recipe payload の exact-minimum retry、runtime envelope の明示的な不確実性と余裕を持つ推奨値、有効な最大値を超える場合の size-reduction 案内、空 / 非空 row、Unicode / escape、通常 payload が要求 UTF-8 cap を超えないことを検証してください。 - JavaScript extension、YAML、batch、SQL dialectのsearch alias variantは、それぞれ1つのlanguage fixtureを再利用し、casing/spelling形式をfact内で反復してください。 + unused JSON byte-budget coverageは、legacy canonical、envelope、compact、by-bucket、empty、minimum、cursor-binding case全体で1つのUnicode graph-ready fixtureを共有してください。stale cursor拒否を証明するindex-generation mutationより前に、read-onlyなpaging / boundary assertionをすべて実行します。16 MiB超のsearch response coverageはpayloadを1回だけserializeし、requested / effective budget pairを反復してください。 raw FTS syntax coverage はvalid control queryと全invalid query/hint variantで1つのindexed sourceを再利用してください。 literalとraw FTSのcomplexity boundはlength、token count、NEAR count、lowercase operator control全体で1つのindexed sourceを再利用してください。 - XAML、Rust、common multi-language、JavaScriptのalias setはそれぞれfixtureを1回だけ構築し、全accepted spelling/casing形式を反復してください。 inline comment-marker exclusionはJavaScript line/block commentとPython line commentを1 indexに置き、marker queryを反復してください。 search exact-mode conflict coverageは全pairwise/triple flag setで1つの空databaseを共有してください。 search path/exclude-pathのinvalid-glob guardは1つの空databaseを共有し、query評価前にoption nameを反復してください。 @@ -1335,6 +1361,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" release-container contract test では `ReadDockerfile()` と `ReadDockerIgnore()` を使い、canonical fixture path が workflow suite 間でずれないようにします。 `RepositoryTestPaths` は checked-in text、normalized derived text、normalized workflow inventory を test process の生命期間 cache します。不変の repository contract だけに使い、fixture を書き換えるテストは独自の一時 path を使ってください。 license-policy contract test は legal notice、workflow file、distribution doc に同じ accessor を使い、repository root の再検出や重複 file read を行いません。 + license-policy workflow の path filter は、shell validation または filter 済みの `LicensePolicyTests` が実際に読むファイルだけに揃えます。どちらからも読まれない文書や test source では、この focused job を起動しません。生成物の `install.sh` と installer/release test source は full Build/Test workflow が引き続き所有します。 repository-backed の documentation、source-audit、JSONL-policy、trimmed-publish test は suite ごとの上位 directory walk を持たず、`RepositoryTestPaths.Root` を再利用します。 大規模な command-runner、installer、extractor suite の legacy root helper も、その単一の cached root へ委譲します。 changelog limit test も別の root walk を行わず、`RepositoryTestPaths` 経由で checked-in file を解決します。 @@ -1425,6 +1452,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は既知の大きな symbol extraction fixture に対する広めの runaway guard です。full suite の負荷に耐えるよう budget は十分広く保ち、benchmark 閾値としてではなく、焦点を絞った最適化根拠がある場合にだけ締めてください。 - `SymbolExtractorTests.Extract_CSharp_LargeSwitchExpression_CompletesWithinPracticalBudget` は10,000個の switch arm と機能的な symbol assertion を維持し、広めの15秒 runaway budget を使います。benchmark 閾値ではなく二乗時間への回帰を検出する tripwire として扱い、余裕幅で負荷の高い full-suite host を吸収してください (#4792)。 +- `ReferenceExtractorPerformanceBudgetTests.Extract_CSharpLargePlainCallFile_CompletesWithinPracticalBudget` と `Extract_CSharpLargeMethodWithManyLocals_CompletesWithinPracticalBudget` + は高負荷な C# reference extraction に対する広めの runaway guard です。C# warmup は `CI=true` かつ `net8.0` test assembly の場合だけ、各 guard が fixture 構築と stopwatch 計測より前に呼ぶ `Lazy` gate により test process ごとに1回実行します。どちらの guard も含まない shard は extraction と強制 GC の固定 warmup cost を負いません。module initializer では hook discovery の delay、persistent worker PID/thread、persistent descendant PID/process の environment 処理をこの順序のまま eager に維持してください。warmup は最後に強制 GC を行うため、この2 guard だけを専用 non-parallel collection に保ち、巨大な `ReferenceExtractorTests` partial class 全体を non-parallel にしないでください。budget は benchmark 閾値ではなく回帰 tripwire として扱い、焦点を絞った最適化根拠がない限り noisy CI に十分な余裕を残してください。 - extractor の広い `*CompletesWithinPracticalBudget` runaway guard は primary の `net8.0` test target だけで実行します。focused な extractor 機能テストは cross-target のまま維持しますが、その guard が target-framework 固有の契約を証明する場合を除き、大規模 fixture の budget guard をすべての target framework で重複実行しないでください。 - C# reflection-name 抽出 coverage は、literal、定数連結、dynamic、comment、string decoy を1つの source fixture にまとめ、これらの parser boundary で1回の symbol/reference pass を共有します。 - C# BOM 抽出は、単純な先頭 BOM import fixture と、CRLF・bare CR・LF 境界で先頭/mid-file BOM を同時に扱う1つの混在改行 fixture を維持します。混在 fixture に含まれる改行 subset ごとに抽出 pass を重複させないでください。 @@ -1601,7 +1630,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" dependency-lock graph の fixture は明示的な親 package → 子 package reference と、合成された top-level package reference が存在しないことを検証します。NuGet と npm の coverage を揃え、同じ resolved package 集合から `deps` と caller traversal のいずれにも lock-file 間の類似 edge が再生成されず、`callers` が要求元 package を container として維持することを確認してください。NuGet の query regression では無関係な2つの lock file と、複数 target / RID に反復する package を使い、file 内に限定された定義、owner scope の callers/callees、各 JSON node の実際の行を検証します。upgrade regression では、以前の dependency-lock extractor / reference-identity contract stamp により unchanged row と stale candidate が無効化されることも検証します。 - `PackagesLockTests.cs` すべての target framework で同期が必要な direct package reference の NuGet lock-file guard。CI の locked restore を通すための net9.0 compatibility reference も対象です。 - workflow restore contract の coverage では、release native lane の locked `net8.0` test-project restore と cross-compile lane の locked production-project restore を区別しつつ、全 surface の exact cache key を維持します。 + workflow restore contract の coverage は各 restore から到達可能な lock file だけを fingerprint にします。Build/Test、CodeQL、license-policy、mutation、native release lane は明示的な6 lock fileのsolution/test graphを共有し、cross-compile release laneは `src/CodeIndex/packages.lock.json` だけを使います。curated release notesとNuGet normalizationは1 lock / 2 lock graphを維持します。`global.json` と無関係な `examples/hooks` lockをNuGet cache identityから除外し、`restore-keys` のないexact-hit cacheを使い、version固定Stryker tool cacheをmutation jobのNuGet cacheから独立させてください。 - `ConcurrencyTests.cs` 並行読み取りと書き込み中読み取りシナリオ(WALモード検証)。issue #180 の bug-catching な snapshot 隔離回帰テストを 3 つの multi-statement reader 経路について含む。(1) `GetStatus` は `refs == files * refsPerFile` の seed 不変条件を立て、並行観測が常にこの条件を維持することを要求する。(2) `AnalyzeSymbol` はシンボル `S` に対して reference/caller を対称に 1 対 1 で seed し、もう 1 ファイルを対称に toggle することで `inspect` / `analyze_symbol` bundle の `references.Count == callers.Count` を常に保証する。(3) `GetRepoMap` はベースラインの modified と新しい toggle 対象ファイルを用意し、`latest_modified == workspace_latest_modified` が常に一致することを要求する。各テストは対応する reader の DEFERRED transaction を外すと落ち、戻すと通ることを確認済み。 - `PerformanceTests.cs` @@ -1638,6 +1667,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" file 単位 maintained hotspot aggregate の回帰 coverage です。legacy database の transactional backfill、高カーディナリティな limit 付き file/name query、aggregate/raw の logical-site parity、cross-file context / identity の無効化、aggregate SQL 開始後の cancellation を含みます。query-plan assertion は `hotspot_reference_counts` の rank index 利用を必須とし、raw `symbol_references` scan を拒否します。benchmark 用の厳しい閾値ではなく、共有の広い deterministic timeout を使います。 - `.github/scripts/run-dotnet-tests.ps1` `dotnet.yml` の matrix test step は、test 引数構築、coverage gating、failure log capture 用の `TestResults` path ownership、TestSessionTimeout handling、1 回だけの flaky retry classification をこのスクリプトに委譲します。workflow YAML は matrix/lane parameter wiring に限定し、script contract や artifact/summarize gating を変更するときは `CiWorkflowTests` も更新してください。 + `dotnet test` の全出力は step log へ stream し続けますが、失敗 attempt の artifact には末尾2,000行だけを保持します。切り詰めた artifact の先頭には retained / total / omitted 行数を明記します。大文字小文字を区別しない `test run timeout` marker は stream 中に検出し、初回 attempt と retry の双方から `ExitCode` と `TestSessionTimedOut` を含む単一の構造化結果だけを返します。artifact を再走査してはいけません。 共有 runsettings の test-session timeout は、workflow の90分 job timeout を下回る75分に保ってください。これにより、遅い Windows lane に完了時間を与えつつ、bounded failure と test 後の cleanup 時間を維持します。 共有 runsettings では `TreatNoTestsAsError` を有効に保ってください。0件一致の初回実行や retry を失敗扱いにし、test を1件も実行せず先行する失敗を green に変えてはいけません。 変換後のcoverage booleanは、大文字小文字を区別しない`CollectCoverage` string parameterとは異なる名前のlocalに保持してください。同名だとPowerShellがtyped helper呼び出し前にbooleanをstringへ戻します。 @@ -1646,6 +1676,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 通常の assertion failure の後、helper は build 済みの `CodeIndex.TestTelemetry retry-filter` command を使い、`test_results_first.trx` から上限付きの VSTest `FullyQualifiedName` filter を生成する。focused retry を使うのは、完全で内部整合した failed TRX で、すべての failed `testId` が filter-safe な test method に一意に対応する場合だけとし、failed result は20件、filter は4,096文字を上限にする。TRX が存在しない、読み取れない、過大、malformed、aborted または incomplete、run-level の host / adapter / data-collector error、内部不整合、対応が曖昧、名前が unsafe、件数超過、長さ超過のいずれかなら、filter のない lane は full suite、filtered lane は現在の full shard に1回だけ fallback する。通常の xUnit skip warning は focused retry の対象に残し、xUnit の `RunInfo outcome="Error"` は、その正確な `[FAIL]` display name が実際の failed result と一致する場合だけ許可する。malformed または対応しない error は、対応する full-suite または full-shard fallback を引き続き強制する。`TestSessionTimeout` の場合は引き続き retry 自体を省略する。どちらの retry scope でも retry 専用 TRX と blame-hang evidence を維持し、coverage と crash collection は初回 attempt だけに限定する。retry が成功した場合は retry scope を含む `flaky-retry.txt` を引き続き作成する。 crash diagnostics は初回 attempt だけで収集し、一過性のhost crashもdumpを残す。flaky classification retry は初回の evidence を再利用して重複するcrash collectorを省略する一方、retry 自体が hang した場合に備えて blame-hang と5分の kill bound は維持する。 TRX telemetry summary は test helper が初回 attempt の失敗を報告した場合(retry 成功を含む)だけ実行する。clean first-pass lane と test 開始前に失敗した job は、2回目の process 起動と TRX parse を支払わない。retry-filter と summary は build 済み telemetry DLL を直接起動し、failure handling で `dotnet run` による project 再評価を行わない。result / dump / coverage artifact upload は test step が開始済みの場合だけに限定し、restore/build failure で空のartifact actionを起動しない。 + primary の `dotnet publish` 出力と `CodeIndex` build artifact は main branch への push または手動 dispatch でだけ materialize し、pull request では作成しません。coverage upload は `TestResults/**/coverage.cobertura.xml` が存在するときだけ起動し、そのfileをfailure向け`TestResults` artifactから除外して、TRX・text log・その他のXML blame evidenceを残しながらcoverageを1回だけ保存します。 - `.github/scripts/configure-windows-test-host.ps1` `dotnet.yml` と `release.yml` の Windows lane は、temp 固定と Defender 除外 setup をこのスクリプトで共有します。通常の `TMP` / `TEMP` は runner の高速な `RUNNER_TEMP\cdidx-temp` を使います。実行可能な plugin / hook / Git fixture だけは `USERPROFILE\cdidx-trusted-test-temp` を使い、current-user 限定の protected ACL と trusted な祖先 chain で production の executable-boundary contract を満たします。この専用 root は `CDIDX_TEST_TRUSTED_TEMP_ROOT` として helper へ渡します。Windows の実行時間を大きく増やすため、通常の SQLite / filesystem fixture を protected root へ移してはいけません。スクリプトは両 root を含む候補 path を正規化・重複排除し、残した各 path と reason を console および利用可能な場合は job summary へ監査表示してから、生成した string array を1回の `Add-MpPreference` 呼び出しで登録し、最後に Defender preference を読み戻して欠けた path があれば失敗します。この split、batching、audit、verification、または workflow 呼び出し contract を変更するときは `CiWorkflowTests` も更新してください。 - `DbRecoveryTests.cs` @@ -1746,7 +1777,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - workspace metadata の result-shape parity は1つの dirty Git fixture から status、map、analysis object を enrich し、同一 repo の initialize / commit を3回繰り返さない。 - persisted HEAD の drift / recovery assertion は1つの Git fixture 内で metadata を更新し、matching state のためだけに2つ目の repo を作成しない。 - latest indexed HEAD の優先順位は1つの seed 済み repo から status / analysis result shape の両方で検証し、同一 Git / DB setup を重複させない。 -- commits-ahead の ancestor / missing-stamp behavior は同じ multi-commit repo を共有する。missing case は `IndexedHeadSha` のない新しい result object だけで検証できる。 +- 通常の `GitHelper` HEAD metadata coverage は、unborn、resolved root/subdirectory、named branch、detached HEAD の assertion で1つの repositoryを再利用し、すべてのbranch assertion後にだけdetachします。non-repository、bare、corrupt metadata、timeout、cancellation pathは分離します。commits-aheadのequal、linear、divergent、invalid-base resultは、indexed baseからsibling branchを分岐した後にfixture所有main branchへempty commitを2件追加する1つのrepositoryで共有します。repositoryの`core.ignorecase` true/false coverageはinitだけのrepositoryとsubdirectoryを再利用し、assertion間でconfigを変更します。commit topologyだけが対象なら`--allow-empty`を使ってください。 +- command-runner result shapeにおけるcommits-aheadのancestor / missing-stamp behaviorは同じmulti-commit repoを共有します。missing caseは`IndexedHeadSha`のない新しいresult objectだけで検証できます。 - shared file-URI escaping と LSP round-trip parity は1つの path / root case で検証し、同等の percent-encoding setup を別 test で重複させない。 - 通常の ad-hoc issue-draft replay coverage は 126 row の fixture を1つだけ seed し、元の selection / metadata と replay 後の値を1つの test で比較してください。platform 固有 shell を起動せず、出力された制限付き POSIX quoting を process 内で parse します。broad な guard 付き検索の safety regression は candidate cap を越えて source lower-bound metadata を検証するため分離し、数百 file ではなく sentinel chunk を持つ1つの indexed file を使います。 - no-timeout sentinel coverage は zero / infinite budget を1つの contract test で検証する。どちらも同じ caller-cancellation path に従うため scope setup を重複させない。 diff --git a/dev.sh b/dev.sh index 3335ef255..16c2b8ef6 100755 --- a/dev.sh +++ b/dev.sh @@ -51,7 +51,7 @@ case "$task" in ;; mcp-smoke) dotnet build src/CodeIndex/CodeIndex.csproj --configuration "$CONFIGURATION" - dotnet run --project src/CodeIndex -- mcp --help > /dev/null + dotnet "src/CodeIndex/bin/$CONFIGURATION/net8.0/cdidx.dll" mcp --help > /dev/null ;; clean) dotnet clean CodeIndex.sln --configuration "$CONFIGURATION" diff --git a/tests/CodeIndex.Tests/CiWorkflowTests.cs b/tests/CodeIndex.Tests/CiWorkflowTests.cs index 6f685f2e5..19f6902a1 100644 --- a/tests/CodeIndex.Tests/CiWorkflowTests.cs +++ b/tests/CodeIndex.Tests/CiWorkflowTests.cs @@ -92,18 +92,10 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() " test-shard: remaining\n" + " test-filter: FullyQualifiedName!~CodeIndex.Tests.IndexCommandRunnerTests", "- name: Set up .NET SDK\n id: setup-dotnet\n continue-on-error: true\n uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0\n with:\n dotnet-version: ${{ matrix.sdk-versions }}", - "- name: Retry .NET SDK setup\n if: steps.setup-dotnet.outcome == 'failure'\n uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0\n with:\n dotnet-version: ${{ matrix.sdk-versions }}", - "- name: Restore dependencies\n if: matrix.primary_lane\n run: dotnet restore CodeIndex.sln --locked-mode", - "- name: Restore test dependencies\n if: ${{ !matrix.primary_lane }}\n run: dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=${{ matrix.test-framework }} --locked-mode"); + "- name: Retry .NET SDK setup\n if: steps.setup-dotnet.outcome == 'failure'\n uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0\n with:\n dotnet-version: ${{ matrix.sdk-versions }}"); AssertDoesNotContainAny( workflow, - "restore-keys:", - "'**/*.csproj'", "function Invoke-TestRun"); - AssertContainsAll( - workflow, - "key: ${{ runner.os }}-dotnet-nuget-${{ hashFiles('**/packages.lock.json', 'global.json') }}", - "primary_lane: true"); AssertContainsAll( workflow, "- name: Audit NuGet package vulnerabilities\n if: matrix.primary_lane", @@ -124,10 +116,10 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() "if ($includeCoverage)", "[ValidateSet(\"true\", \"false\")]", "Skipping XPlat Code Coverage outside ubuntu-24.04/net8.0", - "$firstExitCode = Invoke-TestRun -LogPath $firstLogPath -ResultFileName \"test_results_first.trx\" -IncludeCoverage $includeCoverage -IncludeCrashDiagnostics $true -TestFilter $BaseFilter", + "$firstRunResult = Invoke-TestRun -LogPath $firstLogPath -ResultFileName \"test_results_first.trx\" -IncludeCoverage $includeCoverage -IncludeCrashDiagnostics $true -TestFilter $BaseFilter", "Skipping XPlat Code Coverage on the flaky-classification retry.", "Reusing crash evidence from the initial attempt; the flaky-classification retry skips duplicate crash collection.", - "$retryExitCode = Invoke-TestRun -LogPath $retryLogPath -ResultFileName \"test_results_retry.trx\" -IncludeCoverage $false -IncludeCrashDiagnostics $false", + "$retryRunResult = Invoke-TestRun -LogPath $retryLogPath -ResultFileName \"test_results_retry.trx\" -IncludeCoverage $false -IncludeCrashDiagnostics $false", "\"--no-build\"", "\"--no-restore\"", "$runArgs += \"--blame-crash\"", @@ -140,11 +132,24 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() "$resultsDirectory = \"./TestResults\"", "Join-Path $resultsDirectory \"test-output-first.txt\"", "Join-Path $resultsDirectory \"test-output-retry.txt\"", - "[System.Collections.Generic.List[string]]::new()", + "$failureLogTailLineLimit = 2000", + "[System.Collections.Generic.Queue[string]]::new($failureLogTailLineLimit)", + "$line.IndexOf(\"test run timeout\", [StringComparison]::OrdinalIgnoreCase) -ge 0", + "$testSessionTimedOut = $true", + "[void]$retainedOutputTail.Dequeue()", + "[void]$retainedOutputTail.Enqueue($line)", + "$exitCode = $LASTEXITCODE", "if ($exitCode -ne 0)", "$logDirectory = Split-Path -Parent $LogPath", "New-Item -ItemType Directory -Force -Path $logDirectory", - "[System.IO.File]::WriteAllLines($LogPath, [string[]]$capturedOutput)", + "Test output truncated: retained final", + "were streamed live and omitted from this artifact.", + "[System.IO.File]::WriteAllLines($LogPath, [string[]]$failureLogLines)", + "ExitCode = [int]$exitCode", + "TestSessionTimedOut = [bool]$testSessionTimedOut", + "if ($firstRunResult.TestSessionTimedOut)", + "exit $firstRunResult.ExitCode", + "exit $retryRunResult.ExitCode", "Write-StepOutput -Name \"summarize\" -Value \"true\"", "$env:GITHUB_OUTPUT", "Initial test run hit TestSessionTimeout; skipping flaky retry", @@ -153,6 +158,12 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() AssertDoesNotContainAny( testScript, "New-Item -ItemType Directory -Force -Path ./TestResults", + "Select-String -Path $firstLogPath", + "$capturedOutput.Add($line)", + "return [int]$exitCode", + "[System.IO.File]::WriteAllLines($LogPath, [string[]]$capturedOutput)", + "$firstExitCode", + "$retryExitCode", "Tee-Object", "steps.lane.outputs.primary_lane", "matrix.test-framework"); @@ -167,6 +178,7 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() "TestResults/**/*.trx", "TestResults/**/*.txt", "TestResults/**/*.xml", + "!TestResults/**/coverage.cobertura.xml", "TestResults/**/*.dmp", "TestResults/**/*.dump", "TestResults-${{ matrix.os }}-${{ matrix.test-framework }}-${{ matrix.test-shard }}", @@ -176,8 +188,9 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() AssertContainsAll( workflow, "- name: Upload test results\n if: always() && steps.test.outcome != 'skipped' && (steps.test.outputs.summarize == 'true' || failure())", - "- name: Publish\n if: matrix.primary_lane\n run: dotnet publish src/CodeIndex/CodeIndex.csproj --configuration Release --no-build --no-restore --output publish", - "- name: Upload build artifact\n if: matrix.primary_lane"); + "- name: Publish\n if: matrix.primary_lane && github.event_name != 'pull_request'\n run: dotnet publish src/CodeIndex/CodeIndex.csproj --configuration Release --no-build --no-restore --output publish", + "- name: Upload build artifact\n if: matrix.primary_lane && github.event_name != 'pull_request'", + " path: TestResults/**/coverage.cobertura.xml"); AssertDoesNotContainAny( workflow, "TestResults/**/*Sequence*.xml", @@ -191,10 +204,14 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() "- name: Upload test results\n if: always()\n", "- name: Upload diagnostic dumps\n if: failure()\n", "- name: Upload coverage reports\n if: always() && matrix.primary_lane\n"); + AssertDoesNotContainAny( + workflow, + "- name: Publish\n if: matrix.primary_lane\n", + "- name: Upload build artifact\n if: matrix.primary_lane\n"); AssertContainsAll( workflow, "- name: Upload diagnostic dumps\n if: failure() && steps.test.outcome != 'skipped'", - "- name: Upload coverage reports\n if: always() && matrix.collect_coverage && steps.test.outcome != 'skipped'"); + "- name: Upload coverage reports\n if: always() && matrix.collect_coverage && steps.test.outcome != 'skipped' && hashFiles('TestResults/**/coverage.cobertura.xml') != ''"); Assert.Contains("function Invoke-TestRun", testScript); } @@ -245,14 +262,19 @@ public void WindowsTestHostSetup_SplitsFastAndTrustedTempAndBatchesDefenderExclu var dotnetWorkflow = RepositoryTestPaths.ReadNormalizedDotnetWorkflow(); var releaseWorkflow = RepositoryTestPaths.ReadNormalizedReleaseWorkflow(); var setupScript = RepositoryTestPaths.ReadText(".github", "scripts", "configure-windows-test-host.ps1"); - const string expectedStep = + const string expectedDotnetStep = "- name: Configure Windows test host\n" + " if: runner.os == 'Windows'\n" + " shell: pwsh\n" + " run: ./.github/scripts/configure-windows-test-host.ps1 -Workspace \"${{ github.workspace }}\""; + const string expectedReleaseStep = + "- name: Configure Windows test host\n" + + " if: runner.os == 'Windows' && !matrix.cross_compile\n" + + " shell: pwsh\n" + + " run: ./.github/scripts/configure-windows-test-host.ps1 -Workspace \"${{ github.workspace }}\""; - AssertContainsAll(dotnetWorkflow, expectedStep); - AssertContainsAll(releaseWorkflow, expectedStep); + AssertContainsAll(dotnetWorkflow, expectedDotnetStep); + AssertContainsAll(releaseWorkflow, expectedReleaseStep); AssertDoesNotContainAny(dotnetWorkflow, "Add-MpPreference", "Get-MpPreference"); AssertDoesNotContainAny(releaseWorkflow, "Add-MpPreference", "Get-MpPreference"); AssertContainsAll( @@ -403,25 +425,8 @@ public void GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPo foreach (var cacheBlock in FindStepBlocks(stepBlocks, "actions/cache@")) { - AssertContainsAll( - cacheBlock.Text, - StringComparison.Ordinal, - "hashFiles('**/packages.lock.json', 'global.json')"); AssertDoesNotContainAny(cacheBlock.Text, StringComparison.Ordinal, "restore-keys:", "'**/*.csproj'"); } - - AssertContainsAll( - GetWorkflow(workflows, "dotnet.yml"), - StringComparison.Ordinal, - "key: ${{ runner.os }}-dotnet-nuget-"); - AssertContainsAll( - GetWorkflow(workflows, "release.yml"), - StringComparison.Ordinal, - "key: ${{ runner.os }}-release-nuget-"); - AssertContainsAll( - GetWorkflow(workflows, "mutation-testing.yml"), - StringComparison.Ordinal, - "key: ${{ runner.os }}-mutation-stryker-4.14.0-"); } [Fact] @@ -480,12 +485,26 @@ public void DotnetSdkAndMutationToolVersions_ArePinned() AssertDoesNotContainAny(codeqlWorkflow, "8.0.413", "8.0.x", "9.0.x"); var mutationWorkflow = RepositoryTestPaths.ReadWorkflow("mutation-testing.yml"); + var strykerCacheBlock = Assert.Single( + StepBlockPattern.Matches(mutationWorkflow).Cast(), + block => block.Value.Contains("- name: Cache Stryker tool", StringComparison.Ordinal)); AssertContainsAll( mutationWorkflow, "dotnet tool update --global dotnet-stryker --version 4.14.0", - "if: steps.mutation-cache.outputs.cache-hit != 'true'", - "mutation-stryker-4.14.0"); - AssertDoesNotContainAny(mutationWorkflow, "dotnet tool install --global dotnet-stryker"); + "if: steps.stryker-cache.outputs.cache-hit != 'true'"); + AssertContainsAll( + strykerCacheBlock.Value, + "id: stryker-cache", + "path: ~/.dotnet/tools", + "key: ${{ runner.os }}-mutation-stryker-4.14.0"); + AssertDoesNotContainAny( + strykerCacheBlock.Value, + "hashFiles(", + "~/.nuget/packages"); + AssertDoesNotContainAny( + mutationWorkflow, + "steps.mutation-cache", + "dotnet tool install --global dotnet-stryker"); } [Fact] @@ -502,7 +521,7 @@ public void DotnetWorkflow_UsesSdkCompatibleNuGetAudit() } [Fact] - public void TestingGuide_DocumentsSharedStateParallelismInventory() + public void TestingGuide_DocumentsSharedStateParallelismInventoryAndBoundedCiOutput() { var guide = RepositoryTestPaths.ReadText("TESTING_GUIDE.md"); @@ -518,6 +537,10 @@ public void TestingGuide_DocumentsSharedStateParallelismInventory() "RUNNER_TEMP", ".github/scripts/run-dotnet-tests.ps1", ".github/scripts/configure-windows-test-host.ps1", + "retain only the final 2,000 lines", + "exactly one structured result", + "末尾2,000行だけを保持", + "単一の構造化結果", "共有状態と並列実行の監査"); } @@ -590,6 +613,4 @@ private static void AssertTopLevelContentsPermissionStaysReadOnly(string fileNam Assert.Contains("\n contents: read\n", workflow[..jobsIndex], StringComparison.Ordinal); } - private static string GetWorkflow(IReadOnlyList<(string FileName, string Content)> workflows, string fileName) - => workflows.Single(workflow => string.Equals(workflow.FileName, fileName, StringComparison.Ordinal)).Content; } diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index ccf33d13b..7ee7fd68b 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -47,82 +47,6 @@ public DbReaderTests() _reader = new DbReader(_db.Connection); } - [Theory] - [InlineData("plain", "plain")] - [InlineData("./install.sh", "install.sh")] - [InlineData("/install.sh", "install.sh")] - [InlineData("src/", "src")] - [InlineData("src/Services", "src/Services")] - [InlineData("*.py", "%.py")] - [InlineData("src/*.py", "src/%.py")] - [InlineData("foo?bar", "foo_bar")] - [InlineData(@"literal\*.py", "literal*.py")] - [InlineData(@"literal\?.py", "literal?.py")] - [InlineData(@"literal\[name\].py", "literal[name].py")] - [InlineData(@"src\Foo.cs", @"src\\Foo.cs")] - public void BuildPathLikePattern_TreatsGlobTokensAsWildcards(string input, string expected) - { - Assert.Equal(expected, DbReader.BuildPathLikePattern(input)); - } - - [Theory] - [InlineData("tools", "tools/%")] - [InlineData("./install.sh", "install.sh/%")] - [InlineData("/src/", "src/%")] - public void BuildPathSubtreeLikePattern_NormalizesRepoRelativePrefixes_Issue4163(string input, string expected) - { - Assert.Equal(expected, DbReader.BuildPathSubtreeLikePattern(input)); - } - - [Fact] - public void SqliteIdentifier_Quote_AllowsUnusualTableNamesForSchemaPragmas() - { - using var connection = new SqliteConnection("Data Source=:memory:"); - connection.Open(); - using (var cmd = connection.CreateCommand()) - { - cmd.CommandText = "CREATE TABLE \"odd \"\" table\" (\"odd col\" INTEGER)"; - cmd.ExecuteNonQuery(); - } - - var columns = DbSchemaCache.LoadColumns(connection, "odd \" table"); - - Assert.Contains("odd col", columns); - Assert.Equal("\"odd \"\" table\"", SqliteIdentifier.Quote("odd \" table")); - } - - [Theory] - [InlineData("page_count")] - [InlineData("_pragma1")] - public void SqliteIdentifier_ValidatePragmaName_AllowsBarePragmaNames(string name) - { - Assert.Equal(name, SqliteIdentifier.ValidatePragmaName(name)); - } - - [Theory] - [InlineData("")] - [InlineData("page-count")] - [InlineData("page_count;VACUUM")] - [InlineData("1page_count")] - public void SqliteIdentifier_ValidatePragmaName_RejectsUnsafePragmaNames(string name) - { - Assert.Throws(() => SqliteIdentifier.ValidatePragmaName(name)); - } - - [Fact] - public void DegradationReasonCodes_AllCodesHaveActionableMetadata() - { - foreach (var code in DegradationReasonCodes.All) - { - var metadata = DegradationReasonCodes.GetMetadata(code); - - Assert.Equal(code, metadata.Code); - Assert.False(string.IsNullOrWhiteSpace(metadata.HumanText)); - Assert.Contains("cdidx", metadata.RecommendedAction, StringComparison.Ordinal); - Assert.Contains("cdidx", metadata.AlternativeAction, StringComparison.Ordinal); - } - } - [Fact] public void GetStatus_ExposesOperationalMetrics() { @@ -258,34 +182,6 @@ public void GetStatus_AttributesDatabasePagesWithoutMutatingSource_Issue4888() } } - [Fact] - public void DatabaseSizeAttribution_UnavailableDoesNotReportZeroObjectSizes_Issue4888() - { - var attribution = DbReader.BuildUnavailableDatabaseSizeAttribution( - "dbstat_unavailable", - new StatusDbPragmaSettings - { - PageSize = 4096, - PageCount = 10, - FreelistCount = 2, - }, - logicalDatabaseBytes: 40960, - mainFileBytes: 40960, - walFileBytes: 0, - shmFileBytes: 0, - physicalFileSetBytes: 40960, - freelistBytes: 8192); - - Assert.False(attribution.Available); - Assert.Equal("unavailable", attribution.Measurement); - Assert.Equal("dbstat_unavailable", attribution.UnavailableReason); - Assert.Null(attribution.AllocatedObjectBytes); - Assert.Null(attribution.TableBytes); - Assert.Null(attribution.IndexBytes); - Assert.Null(attribution.UnexplainedResidualBytes); - Assert.Null(attribution.TopObjects); - } - [Fact] public void DatabaseSizeAttribution_CorruptDatabaseHeaderFailsClosed_Issue4888() { @@ -584,18 +480,6 @@ public void GetStatus_NormalizesIndexedHeadTimestampOffsetForMachineJson_Issue43 Assert.Equal(expected, nestedParsed); } - [Theory] - [InlineData(DegradationReasonCodes.MissingFoldBackfill, "--exact falls back")] - [InlineData(DegradationReasonCodes.StaleFoldKeyVersion, "older fold-key version")] - [InlineData(DegradationReasonCodes.StaleFoldKeyFingerprint, "older runtime fingerprint")] - [InlineData(DegradationReasonCodes.FoldRowsNotRestamped, "not restamped")] - public void DegradationReasonCodes_BuildsFoldExplanationFromCode(string code, string expectedText) - { - var explanation = DegradationReasonCodes.BuildFoldNotReadyExplanation(code); - - Assert.Contains(expectedText, explanation, StringComparison.Ordinal); - } - [Fact] public void CountSearchResults_NormalizesJavascriptLangSpelling() { @@ -612,23 +496,6 @@ public void CountSearchResults_NormalizesJavascriptLangSpelling() Assert.Equal(1, counts.FileCount); } - [Fact] - public void AnalyzeFtsQuery_AllTokensTooLong_ReturnsDegradedReason() - { - var query = new string('x', DbReader.FtsUnicode61MaxTokenLength + 1); - - var diagnostics = DbReader.AnalyzeFtsQuery(query); - - Assert.Equal(DbReader.AllTokensFilteredByLengthReason, diagnostics.QueryDegradedReason); - Assert.Equal([query], diagnostics.TokensDropped); - } - - - - - - - [Fact] public void CountSearchResults_RawFtsRejectsUnknownColumnQualifiersBeforeSqlite() { @@ -1117,21 +984,6 @@ public void FileCountHelpers_UseGroupedReferenceCounts() Assert.Equal("COALESCE(reference_counts.reference_count, 0)", countSql); } - [Fact] - public void NormalizeSymbolSearchQueries_SkipsAlreadyNormalizedInput() - { - var method = typeof(DbReader).GetMethod( - "NormalizeSymbolSearchQueries", - BindingFlags.Static | BindingFlags.NonPublic); - Assert.NotNull(method); - - var normalized = Assert.IsAssignableFrom>(method!.Invoke(null, [new[] { "module.exports.fetchData", "module.exports.fetchData" }, "javascript", false])); - var secondPass = Assert.IsAssignableFrom>(method.Invoke(null, [normalized, "javascript", false])); - - Assert.Same(normalized, secondPass); - Assert.Equal(["fetchData"], normalized); - } - [Theory] [InlineData("js")] [InlineData("JS")] diff --git a/tests/CodeIndex.Tests/DbReaderUtilityTests.cs b/tests/CodeIndex.Tests/DbReaderUtilityTests.cs new file mode 100644 index 000000000..306df788d --- /dev/null +++ b/tests/CodeIndex.Tests/DbReaderUtilityTests.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using CodeIndex.Database; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +public class DbReaderUtilityTests +{ + [Theory] + [InlineData("plain", "plain")] + [InlineData("./install.sh", "install.sh")] + [InlineData("/install.sh", "install.sh")] + [InlineData("src/", "src")] + [InlineData("src/Services", "src/Services")] + [InlineData("*.py", "%.py")] + [InlineData("src/*.py", "src/%.py")] + [InlineData("foo?bar", "foo_bar")] + [InlineData(@"literal\*.py", "literal*.py")] + [InlineData(@"literal\?.py", "literal?.py")] + [InlineData(@"literal\[name\].py", "literal[name].py")] + [InlineData(@"src\Foo.cs", @"src\\Foo.cs")] + public void BuildPathLikePattern_TreatsGlobTokensAsWildcards(string input, string expected) + { + Assert.Equal(expected, DbReader.BuildPathLikePattern(input)); + } + + [Theory] + [InlineData("tools", "tools/%")] + [InlineData("./install.sh", "install.sh/%")] + [InlineData("/src/", "src/%")] + public void BuildPathSubtreeLikePattern_NormalizesRepoRelativePrefixes_Issue4163(string input, string expected) + { + Assert.Equal(expected, DbReader.BuildPathSubtreeLikePattern(input)); + } + + [Fact] + public void SqliteIdentifier_Quote_AllowsUnusualTableNamesForSchemaPragmas() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "CREATE TABLE \"odd \"\" table\" (\"odd col\" INTEGER)"; + cmd.ExecuteNonQuery(); + } + + var columns = DbSchemaCache.LoadColumns(connection, "odd \" table"); + + Assert.Contains("odd col", columns); + Assert.Equal("\"odd \"\" table\"", SqliteIdentifier.Quote("odd \" table")); + } + + [Theory] + [InlineData("page_count")] + [InlineData("_pragma1")] + public void SqliteIdentifier_ValidatePragmaName_AllowsBarePragmaNames(string name) + { + Assert.Equal(name, SqliteIdentifier.ValidatePragmaName(name)); + } + + [Theory] + [InlineData("")] + [InlineData("page-count")] + [InlineData("page_count;VACUUM")] + [InlineData("1page_count")] + public void SqliteIdentifier_ValidatePragmaName_RejectsUnsafePragmaNames(string name) + { + Assert.Throws(() => SqliteIdentifier.ValidatePragmaName(name)); + } + + [Fact] + public void DegradationReasonCodes_AllCodesHaveActionableMetadata() + { + foreach (var code in DegradationReasonCodes.All) + { + var metadata = DegradationReasonCodes.GetMetadata(code); + + Assert.Equal(code, metadata.Code); + Assert.False(string.IsNullOrWhiteSpace(metadata.HumanText)); + Assert.Contains("cdidx", metadata.RecommendedAction, StringComparison.Ordinal); + Assert.Contains("cdidx", metadata.AlternativeAction, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(DegradationReasonCodes.MissingFoldBackfill, "--exact falls back")] + [InlineData(DegradationReasonCodes.StaleFoldKeyVersion, "older fold-key version")] + [InlineData(DegradationReasonCodes.StaleFoldKeyFingerprint, "older runtime fingerprint")] + [InlineData(DegradationReasonCodes.FoldRowsNotRestamped, "not restamped")] + public void DegradationReasonCodes_BuildsFoldExplanationFromCode(string code, string expectedText) + { + var explanation = DegradationReasonCodes.BuildFoldNotReadyExplanation(code); + + Assert.Contains(expectedText, explanation, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseSizeAttribution_UnavailableDoesNotReportZeroObjectSizes_Issue4888() + { + var attribution = DbReader.BuildUnavailableDatabaseSizeAttribution( + "dbstat_unavailable", + new StatusDbPragmaSettings + { + PageSize = 4096, + PageCount = 10, + FreelistCount = 2, + }, + logicalDatabaseBytes: 40960, + mainFileBytes: 40960, + walFileBytes: 0, + shmFileBytes: 0, + physicalFileSetBytes: 40960, + freelistBytes: 8192); + + Assert.False(attribution.Available); + Assert.Equal("unavailable", attribution.Measurement); + Assert.Equal("dbstat_unavailable", attribution.UnavailableReason); + Assert.Null(attribution.AllocatedObjectBytes); + Assert.Null(attribution.TableBytes); + Assert.Null(attribution.IndexBytes); + Assert.Null(attribution.UnexplainedResidualBytes); + Assert.Null(attribution.TopObjects); + } + + [Fact] + public void AnalyzeFtsQuery_AllTokensTooLong_ReturnsDegradedReason() + { + var query = new string('x', DbReader.FtsUnicode61MaxTokenLength + 1); + + var diagnostics = DbReader.AnalyzeFtsQuery(query); + + Assert.Equal(DbReader.AllTokensFilteredByLengthReason, diagnostics.QueryDegradedReason); + Assert.Equal([query], diagnostics.TokensDropped); + } + + [Fact] + public void NormalizeSymbolSearchQueries_SkipsAlreadyNormalizedInput() + { + var method = typeof(DbReader).GetMethod( + "NormalizeSymbolSearchQueries", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(method); + + var normalized = Assert.IsAssignableFrom>(method!.Invoke(null, [new[] { "module.exports.fetchData", "module.exports.fetchData" }, "javascript", false])); + var secondPass = Assert.IsAssignableFrom>(method.Invoke(null, [normalized, "javascript", false])); + + Assert.Same(normalized, secondPass); + Assert.Equal(["fetchData"], normalized); + } +} diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index ad42e70a5..c34edec87 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -1352,72 +1352,26 @@ public void GetChangedFilesFromCommit_RejectsNonCommitIdRefs(string commitRef) } [ExternalProcessFact] - public void TryGetHeadCommit_ReturnsHeadCommitForRepo() + public void HeadMetadataLifecycle_UnbornResolvedSubdirectoryAndDetached_ReturnsConsistentValues() { var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - - var expected = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - var actual = GitHelper.TryGetHeadCommit(repoDir); - - Assert.Equal(expected, actual); - } - - [ExternalProcessFact] - public void TryGetHeadCommitResult_ReturnsResolvedForBranchHead() - { - var repoDir = CreateGitRepo(); - - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); + AssertHeadResult(GitHelper.TryGetHeadCommitResult(repoDir), GitHeadCommitState.None, expectedSha: null); + RunGit(repoDir, "commit", "--allow-empty", "-m", "initial"); + RunGit(repoDir, "branch", "-M", "cdidx-head-lifecycle"); var expected = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - var actual = GitHelper.TryGetHeadCommitResult(repoDir); - - Assert.Equal(GitHeadCommitState.Resolved, actual.State); - Assert.Equal(expected, actual.Sha); - Assert.Null(actual.Reason); - } - - [ExternalProcessFact] - public void TryGetHeadCommitResult_ReturnsResolvedForRepositorySubdirectory() - { - var repoDir = CreateGitRepo(); - - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); var projectDir = Path.Combine(repoDir, "src", "App"); Directory.CreateDirectory(projectDir); - var expected = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - var actual = GitHelper.TryGetHeadCommitResult(projectDir); - - Assert.Equal(GitHeadCommitState.Resolved, actual.State); - Assert.Equal(expected, actual.Sha); - Assert.Null(actual.Reason); - } - - [ExternalProcessFact] - public void TryGetHeadCommitResult_ReturnsDetachedHeadWithSha() - { - var repoDir = CreateGitRepo(); - - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - var sha = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - RunGit(repoDir, "checkout", "--detach", sha); - - var actual = GitHelper.TryGetHeadCommitResult(repoDir); + Assert.Equal(expected, GitHelper.TryGetHeadCommit(repoDir)); + AssertHeadResult(GitHelper.TryGetHeadCommitResult(repoDir), GitHeadCommitState.Resolved, expected); + AssertHeadResult(GitHelper.TryGetHeadCommitResult(projectDir), GitHeadCommitState.Resolved, expected); + Assert.Equal("cdidx-head-lifecycle", GitHelper.TryGetHeadBranch(repoDir)); - Assert.Equal(GitHeadCommitState.DetachedHead, actual.State); - Assert.Equal(sha, actual.Sha); - Assert.Null(actual.Reason); + RunGit(repoDir, "checkout", "--detach", expected); + AssertHeadResult(GitHelper.TryGetHeadCommitResult(repoDir), GitHeadCommitState.DetachedHead, expected); + Assert.Null(GitHelper.TryGetHeadBranch(repoDir)); } [ExternalProcessFact] @@ -1498,18 +1452,6 @@ public void TryResolveCommit_CanceledTokenStopsGitProcess_Issue3723() $"git cancellation should stop before the fake git sleep completes; elapsed={stopwatch.Elapsed}"); } - [ExternalProcessFact] - public void TryGetHeadCommitResult_ReturnsNoneForUnbornHead() - { - var repoDir = CreateGitRepo(); - - var actual = GitHelper.TryGetHeadCommitResult(repoDir); - - Assert.Equal(GitHeadCommitState.None, actual.State); - Assert.Null(actual.Sha); - Assert.Null(actual.Reason); - } - [ExternalProcessFact] public void TryGetHeadCommitResult_ReturnsErrorForCorruptGitDirectory() { @@ -1542,109 +1484,25 @@ public void TryGetHeadCommitResult_ReturnsResolvedForBareRepository() } [ExternalProcessFact] - public void TryGetHeadBranch_ReturnsBranchShortName() - { - var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - - // Force a deterministic branch name so the assertion isn't sensitive to the - // local `init.defaultBranch` setting on the dev machine. - // ローカル設定の影響を避けるためブランチを明示的に切り替える。 - RunGit(repoDir, "switch", "-c", "feature"); - - Assert.Equal("feature", GitHelper.TryGetHeadBranch(repoDir)); - } - - [ExternalProcessFact] - public void TryGetHeadBranch_ReturnsNullOnDetachedHead() - { - var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - var sha = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - // `git checkout ` detaches HEAD; rev-parse --abbrev-ref then prints "HEAD". - // We must not surface that literal "HEAD" as a real branch name. Issue #1509. - // detached HEAD では文字列 "HEAD" を branch 名として誤って返さないことを保証する。 - RunGit(repoDir, "checkout", "--detach", sha); - - Assert.Null(GitHelper.TryGetHeadBranch(repoDir)); - } - - [ExternalProcessFact] - public void TryCountCommitsAhead_ReturnsZeroWhenIndexedShaEqualsCurrent() - { - var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - var sha = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - - Assert.Equal(0, GitHelper.TryCountCommitsAhead(repoDir, sha)); - } - - [ExternalProcessFact] - public void TryCountCommitsAhead_CountsCommitsBetweenIndexedAndCurrentHead() + public void TryCountCommitsAhead_EqualLinearDivergentAndInvalidBases_ReturnsExpectedCounts() { var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); + RunGit(repoDir, "commit", "--allow-empty", "-m", "base"); + RunGit(repoDir, "branch", "-M", "cdidx-ahead-main"); var indexedSha = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v2\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "second"); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v3\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "third"); - - Assert.Equal(2, GitHelper.TryCountCommitsAhead(repoDir, indexedSha)); - } - - [ExternalProcessFact] - public void TryCountCommitsAhead_ReturnsNullWhenIndexedShaIsNotAncestor() - { - var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "base\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "base"); - var defaultBranch = RunGit(repoDir, "rev-parse", "--abbrev-ref", "HEAD").Trim(); + Assert.Equal(0, GitHelper.TryCountCommitsAhead(repoDir, indexedSha)); - // Create a divergent commit, capture its SHA, then switch back to the - // original branch so the diverged commit is no longer reachable from HEAD. - // "Ahead by N" is not meaningful here, so the helper must report null - // instead of a misleading 0. - // 非祖先 commit に対しては「N コミット進んでいる」は意味を成さないので null を返す。 - RunGit(repoDir, "switch", "-c", "divergent"); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "divergent\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "divergent"); + RunGit(repoDir, "switch", "-c", "cdidx-ahead-divergent"); + RunGit(repoDir, "commit", "--allow-empty", "-m", "divergent"); var divergentSha = RunGit(repoDir, "rev-parse", "HEAD").Trim(); - // Switch back to the original branch and add another commit on its lineage. - RunGit(repoDir, "switch", defaultBranch); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "after\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "after"); + RunGit(repoDir, "switch", "cdidx-ahead-main"); + RunGit(repoDir, "commit", "--allow-empty", "-m", "second"); + RunGit(repoDir, "commit", "--allow-empty", "-m", "third"); + Assert.Equal(2, GitHelper.TryCountCommitsAhead(repoDir, indexedSha)); Assert.Null(GitHelper.TryCountCommitsAhead(repoDir, divergentSha)); - } - - [ExternalProcessFact] - public void TryCountCommitsAhead_RejectsArgumentInjectionAttempts() - { - var repoDir = CreateGitRepo(); - File.WriteAllText(Path.Combine(repoDir, "tracked.txt"), "v1\n"); - RunGit(repoDir, "add", "tracked.txt"); - RunGit(repoDir, "commit", "-m", "initial"); - - // The helper must reject values that look like git options, mirroring the - // existing GetChangedFilesFromCommit validation, so a caller cannot smuggle - // `--exec` or similar payloads through the stamped indexed_head_sha. - // 永続化された stamp 経由で git オプションが流れ込まないよう dash 始まりを拒否する。 Assert.Null(GitHelper.TryCountCommitsAhead(repoDir, "--upload-pack=evil")); Assert.Null(GitHelper.TryCountCommitsAhead(repoDir, string.Empty)); } @@ -1691,42 +1549,20 @@ public void TryGetWorktreeStatus_DetectsUnresolvedMergeFiles() } [ExternalProcessFact] - public void ResolveIgnoreCase_UsesGitConfigWhenRepositorySetsTrue() - { - var repoDir = CreateGitRepo(); - RunGit(repoDir, "config", "core.ignorecase", "true"); - - Assert.True(GitHelper.ResolveIgnoreCase(repoDir)); - } - - [ExternalProcessFact] - public void ResolveIgnoreCase_UsesGitConfigWhenRepositorySetsFalse() - { - var repoDir = CreateGitRepo(); - RunGit(repoDir, "config", "core.ignorecase", "false"); - - Assert.False(GitHelper.ResolveIgnoreCase(repoDir)); - } - - [ExternalProcessFact] - public void ResolveIgnoreCase_UsesGitConfigWhenProjectPathIsSubdirectoryAndRepositorySetsTrue() + public void ResolveIgnoreCase_RootAndSubdirectoryAcrossConfigChanges_ReturnsConfiguredValue() { - var repoDir = CreateGitRepo(); + var repoDir = CreateInitializedGitRepo(); var subDir = Path.Combine(repoDir, "src", "module"); Directory.CreateDirectory(subDir); + RunGit(repoDir, "config", "core.ignorecase", "true"); + Assert.True(GitHelper.ResolveIgnoreCase(repoDir)); Assert.True(GitHelper.ResolveIgnoreCase(subDir)); - } - [ExternalProcessFact] - public void ResolveIgnoreCase_UsesGitConfigWhenProjectPathIsSubdirectoryAndRepositorySetsFalse() - { - var repoDir = CreateGitRepo(); - var subDir = Path.Combine(repoDir, "src", "module"); - Directory.CreateDirectory(subDir); RunGit(repoDir, "config", "core.ignorecase", "false"); + Assert.False(GitHelper.ResolveIgnoreCase(repoDir)); Assert.False(GitHelper.ResolveIgnoreCase(subDir)); } @@ -1799,10 +1635,8 @@ public void ResolveIgnoreCase_ProbeFailureThrowsStructuredFilesystemError_Issue3 private string CreateGitRepo() { - var repoDir = Path.Combine(_tempDir, $"repo_{Guid.NewGuid():N}"); - Directory.CreateDirectory(repoDir); + var repoDir = CreateInitializedGitRepo(); - RunGit(repoDir, "init"); RunGit(repoDir, "config", "user.name", "CodeIndex Tests"); RunGit(repoDir, "config", "user.email", "tests@example.com"); RunGit(repoDir, "config", "commit.gpgsign", "false"); @@ -1811,6 +1645,26 @@ private string CreateGitRepo() return repoDir; } + private string CreateInitializedGitRepo() + { + var repoDir = Path.Combine(_tempDir, $"repo_{Guid.NewGuid():N}"); + Directory.CreateDirectory(repoDir); + + RunGit(repoDir, "init"); + + return repoDir; + } + + private static void AssertHeadResult( + GitHeadCommitResult actual, + GitHeadCommitState expectedState, + string? expectedSha) + { + Assert.Equal(expectedState, actual.State); + Assert.Equal(expectedSha, actual.Sha); + Assert.Null(actual.Reason); + } + private static string RunGit(string workDir, params string[] args) => RunGitWithEnvironment(workDir, environment: null, args); diff --git a/tests/CodeIndex.Tests/LicensePolicyTests.cs b/tests/CodeIndex.Tests/LicensePolicyTests.cs index 65e7f32b2..33d3e736e 100644 --- a/tests/CodeIndex.Tests/LicensePolicyTests.cs +++ b/tests/CodeIndex.Tests/LicensePolicyTests.cs @@ -36,21 +36,15 @@ public class LicensePolicyTests "TRADEMARKS.md", "README.md", "USER_GUIDE.md", - "DEVELOPER_GUIDE.md", "DISTRIBUTION.md", "docs/NUGET_README.md", - "MAINTAINERS.md", - "CONTRIBUTING.md", "src/CodeIndex/CodeIndex.csproj", "src/CodeIndex/Cli/ConsoleUi.cs", - "install.sh", "install_modules/20-installer.sh", "install_modules/40-uninstall.sh", ".github/workflows/release.yml", ".github/workflows/license-policy.yml", "tests/CodeIndex.Tests/LicensePolicyTests.cs", - "tests/CodeIndex.Tests/InstallScriptTests.cs", - "tests/CodeIndex.Tests/ReleaseWorkflowTests.cs", ]; [Fact] @@ -169,14 +163,12 @@ public void LicenseDistributionSurfacesStayAligned_Issue4172() Assert.Contains("distribution are allowed for non-competing purposes", licenseSummary); Assert.Contains("separate written agreement with Widthdom", licenseSummary); - foreach (var triggerPath in LicensePolicyWorkflowTriggerPaths) - Assert.Equal(2, CountOccurrences(policyWorkflow, $"- '{triggerPath}'")); + Assert.Equal(16, LicensePolicyWorkflowTriggerPaths.Length); + Assert.Equal(LicensePolicyWorkflowTriggerPaths, ReadWorkflowTriggerPaths(policyWorkflow, "push")); + Assert.Equal(LicensePolicyWorkflowTriggerPaths, ReadWorkflowTriggerPaths(policyWorkflow, "pull_request")); Assert.Contains("actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0", policyWorkflow); Assert.Contains("8.0.413", policyWorkflow); Assert.Contains("9.0.301", policyWorkflow); - Assert.Contains("cache: true", policyWorkflow); - Assert.Contains("cache-dependency-path: '**/packages.lock.json'", policyWorkflow); - Assert.Contains("dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=net8.0 --locked-mode", policyWorkflow); Assert.Contains("dotnet test tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework net8.0 --filter FullyQualifiedName~LicensePolicyTests --no-restore --nologo", policyWorkflow); AssertContainsAll(readme, new[] @@ -211,6 +203,29 @@ private static int CountOccurrences(string haystack, string needle) return count; } + private static string[] ReadWorkflowTriggerPaths(string workflow, string eventName) + { + const string pathsMarker = " paths:"; + const string pathPrefix = " - '"; + var lines = workflow.ReplaceLineEndings("\n").Split('\n'); + var eventHeader = $" {eventName}:"; + var eventStart = Array.IndexOf(lines, eventHeader); + Assert.True(eventStart >= 0, $"Workflow event '{eventName}' is missing."); + + var eventLines = lines + .Skip(eventStart + 1) + .TakeWhile(static line => line.StartsWith(" ", StringComparison.Ordinal)) + .ToArray(); + var pathsStart = Array.IndexOf(eventLines, pathsMarker); + Assert.True(pathsStart >= 0, $"Workflow event '{eventName}' is missing its paths filter."); + + return eventLines + .Skip(pathsStart + 1) + .TakeWhile(static line => line.StartsWith(pathPrefix, StringComparison.Ordinal)) + .Select(static line => line.Trim()[3..^1]) + .ToArray(); + } + private static void AssertContainsAll(string haystack, IEnumerable needles) { foreach (var needle in needles) diff --git a/tests/CodeIndex.Tests/PackagesLockTests.cs b/tests/CodeIndex.Tests/PackagesLockTests.cs index c4f961b3c..3b2ac8911 100644 --- a/tests/CodeIndex.Tests/PackagesLockTests.cs +++ b/tests/CodeIndex.Tests/PackagesLockTests.cs @@ -16,6 +16,23 @@ public class PackagesLockTests "tools/CodeIndex.TestTelemetry/CodeIndex.TestTelemetry.csproj", ]; + private static readonly string[] SolutionRestoreLockFiles = + [ + "src/CodeIndex/packages.lock.json", + "tests/CodeIndex.HookIsolationFixture/packages.lock.json", + "tests/CodeIndex.Tests/packages.lock.json", + "tools/CodeIndex.Changelog/packages.lock.json", + "tools/CodeIndex.PackageNormalize/packages.lock.json", + "tools/CodeIndex.TestTelemetry/packages.lock.json", + ]; + + private const string SolutionRestoreLockHashExpression = + "${{ hashFiles('src/CodeIndex/packages.lock.json', 'tests/CodeIndex.HookIsolationFixture/packages.lock.json', 'tests/CodeIndex.Tests/packages.lock.json', 'tools/CodeIndex.Changelog/packages.lock.json', 'tools/CodeIndex.PackageNormalize/packages.lock.json', 'tools/CodeIndex.TestTelemetry/packages.lock.json') }}"; + + private static readonly string SolutionRestoreCacheDependencyPath = + "cache-dependency-path: |\n" + + string.Join("\n", SolutionRestoreLockFiles.Select(static path => $" {path}")); + [Fact] public void DirectoryBuildProps_EnablesLockFilesWithoutForcingLocalLockedMode() { @@ -80,23 +97,91 @@ public void RestoreSurfaces_UseLockedModeExactCacheKeysAndDockerRidRestore() var dotnetWorkflow = RepositoryTestPaths.ReadNormalizedDotnetWorkflow(); var releaseWorkflow = RepositoryTestPaths.ReadNormalizedWorkflow("release.yml"); var codeqlWorkflow = RepositoryTestPaths.ReadNormalizedWorkflow("codeql.yml"); + var licenseWorkflow = RepositoryTestPaths.ReadNormalizedWorkflow("license-policy.yml"); var mutationWorkflow = RepositoryTestPaths.ReadNormalizedWorkflow("mutation-testing.yml"); var dockerfile = RepositoryTestPaths.ReadNormalizedText("Dockerfile"); Assert.Contains("dotnet restore CodeIndex.sln --locked-mode", dotnetWorkflow, StringComparison.Ordinal); + Assert.Contains( + "dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=${{ matrix.test-framework }} --locked-mode", + dotnetWorkflow, + StringComparison.Ordinal); Assert.Contains( "dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=net8.0 --locked-mode", releaseWorkflow, StringComparison.Ordinal); + Assert.Contains( + "dotnet restore src/CodeIndex/CodeIndex.csproj --locked-mode", + releaseWorkflow, + StringComparison.Ordinal); Assert.Contains("dotnet restore CodeIndex.sln --locked-mode", codeqlWorkflow, StringComparison.Ordinal); - Assert.Contains("cache-dependency-path: '**/packages.lock.json'", codeqlWorkflow, StringComparison.Ordinal); + Assert.Contains( + "dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=net8.0 --locked-mode", + licenseWorkflow, + StringComparison.Ordinal); Assert.Contains("dotnet restore CodeIndex.sln --locked-mode", mutationWorkflow, StringComparison.Ordinal); - Assert.DoesNotContain("restore-keys:", dotnetWorkflow, StringComparison.Ordinal); - Assert.DoesNotContain("restore-keys:", releaseWorkflow, StringComparison.Ordinal); - Assert.DoesNotContain("restore-keys:", mutationWorkflow, StringComparison.Ordinal); - Assert.Contains("key: ${{ runner.os }}-dotnet-nuget-${{ hashFiles('**/packages.lock.json', 'global.json') }}", dotnetWorkflow, StringComparison.Ordinal); - Assert.Contains("key: ${{ runner.os }}-mutation-stryker-4.14.0-${{ hashFiles('**/packages.lock.json', 'global.json') }}", mutationWorkflow, StringComparison.Ordinal); + foreach (var setupDotnetCachedWorkflow in new[] { codeqlWorkflow, licenseWorkflow }) + { + Assert.Contains("cache: true", setupDotnetCachedWorkflow, StringComparison.Ordinal); + Assert.Contains(SolutionRestoreCacheDependencyPath, setupDotnetCachedWorkflow, StringComparison.Ordinal); + } + Assert.Contains( + "key: ${{ runner.os }}-dotnet-nuget-" + SolutionRestoreLockHashExpression, + dotnetWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "path: ~/.nuget/packages\n" + + " key: ${{ runner.os }}-mutation-nuget-" + SolutionRestoreLockHashExpression, + mutationWorkflow, + StringComparison.Ordinal); + Assert.DoesNotContain("Cache Stryker tool and NuGet packages", mutationWorkflow, StringComparison.Ordinal); + Assert.Contains( + "- name: Cache native NuGet packages\n" + + " if: ${{ !matrix.cross_compile }}", + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "key: ${{ runner.os }}-release-nuget-" + SolutionRestoreLockHashExpression, + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "- name: Cache cross-compile NuGet packages\n" + + " if: matrix.cross_compile", + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "key: ${{ runner.os }}-release-cross-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json') }}", + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "cache-dependency-path: tools/CodeIndex.Changelog/packages.lock.json", + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "dotnet restore tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj --locked-mode", + releaseWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "cache-dependency-path: |\n" + + " src/CodeIndex/packages.lock.json\n" + + " tools/CodeIndex.PackageNormalize/packages.lock.json", + releaseWorkflow, + StringComparison.Ordinal); + + foreach (var workflow in new[] + { + dotnetWorkflow, + releaseWorkflow, + codeqlWorkflow, + licenseWorkflow, + mutationWorkflow, + }) + { + Assert.DoesNotContain("'**/packages.lock.json'", workflow, StringComparison.Ordinal); + Assert.DoesNotContain("global.json", workflow, StringComparison.Ordinal); + Assert.DoesNotContain("examples/hooks/packages.lock.json", workflow, StringComparison.Ordinal); + } Assert.DoesNotContain("dotnet restore src/CodeIndex/CodeIndex.csproj \\\n --runtime \"$rid\"", dockerfile, StringComparison.Ordinal); Assert.DoesNotContain("--runtime \"$rid\" \\\n --no-restore", dockerfile, StringComparison.Ordinal); diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index a5e279275..6712a9cac 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -605,37 +605,9 @@ public void ExportCtags_WritesTagsFileFromIndexedSymbols() } [ProductionRuntimeFact] - public void ExportImportArchive_RestoresCodeIndexDatabase() + public void ExportImportArchive_SharesMetadataRichPristineAcrossSuccessPaths_Issue3549() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_archive"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); - - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath]); - - Assert.True(exportExit == 0, exportStderr); - Assert.Equal(string.Empty, exportStderr); - Assert.True(importExit == 0, importStderr); - Assert.Equal(string.Empty, importStderr); - Assert.Contains("Imported CodeIndex database", importStdout); - Assert.True(File.Exists(importedDbPath)); - Assert.True(DbContext.TryValidateExistingCodeIndexDb(importedDbPath, out _, out _)); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [ProductionRuntimeFact] - public void ExportArchive_ManifestIncludesReadinessAndSummaryMetadata_Issue3549() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_manifest_metadata"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_archive_success_paths"); try { var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); @@ -659,86 +631,57 @@ public void ExportArchive_ManifestIncludesReadinessAndSummaryMetadata_Issue3549( writer.SetMeta(DbContext.UnknownExtensionFilePathLimitMetaKey, "50"); } - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var pristineArchivePath = Path.Combine(projectRoot, "pristine.cdidx.zip"); + var defaultImportDbPath = Path.Combine(projectRoot, "default-import", "codeindex.db"); + var legacyArchivePath = Path.Combine(projectRoot, "legacy.cdidx.zip"); + var legacyImportDbPath = Path.Combine(projectRoot, "legacy-import", "codeindex.db"); + var noBackupDbPath = Path.Combine(projectRoot, "no-backup-replacement", "codeindex.db"); - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", pristineArchivePath, "--db", sourceDbPath]); Assert.True(exportExit == 0, exportStderr); Assert.Equal(string.Empty, exportStderr); - using var archive = ZipFile.OpenRead(archivePath); - var manifestEntry = archive.GetEntry("manifest.json") - ?? throw new InvalidOperationException("manifest.json entry was not found"); - using var document = JsonDocument.Parse(manifestEntry.Open()); - var root = document.RootElement; - Assert.Equal(1, root.GetProperty("file_count").GetInt64()); - Assert.True(root.GetProperty("chunk_count").GetInt64() >= 1); - Assert.True(root.GetProperty("symbol_count").GetInt64() >= 1); - Assert.True(root.GetProperty("reference_count").GetInt64() >= 0); - Assert.Equal("test-writer", root.GetProperty("index_writer_version").GetString()); - Assert.Equal("main", root.GetProperty("indexed_head_branch").GetString()); - Assert.Equal("2026-06-11T00:00:00Z", root.GetProperty("indexed_head_timestamp").GetString()); - Assert.Equal(1, root.GetProperty("codeindex_meta_schema_version").GetInt32()); - Assert.Equal(2, root.GetProperty("csharp_symbol_name_contract_version").GetInt32()); - Assert.Equal(1, root.GetProperty("sql_graph_contract_version").GetInt32()); - Assert.Equal(2, root.GetProperty("hotspot_family_version").GetInt32()); - Assert.Equal(2, root.GetProperty("unknown_extension_file_count").GetInt64()); - Assert.False(root.GetProperty("unknown_extension_files_truncated").GetBoolean()); - Assert.Equal(50, root.GetProperty("unknown_extension_file_path_limit").GetInt32()); - Assert.Equal("tools/custom.foo", root.GetProperty("unknown_extension_files")[0].GetString()); - Assert.Equal(JsonValueKind.True, root.GetProperty("graph_ready").ValueKind); - Assert.Equal(JsonValueKind.True, root.GetProperty("issues_ready").ValueKind); - Assert.Equal(JsonValueKind.True, root.GetProperty("fold_ready").ValueKind); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [ProductionRuntimeFact] - public void ImportArchive_RejectsManifestFileCountMismatch_Issue3549() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_manifest_count_mismatch"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); + using (var archive = ZipFile.OpenRead(pristineArchivePath)) + { + var manifestEntry = archive.GetEntry("manifest.json") + ?? throw new InvalidOperationException("manifest.json entry was not found"); + using var manifestStream = manifestEntry.Open(); + using var document = JsonDocument.Parse(manifestStream); + var root = document.RootElement; + Assert.Equal(1, root.GetProperty("file_count").GetInt64()); + Assert.True(root.GetProperty("chunk_count").GetInt64() >= 1); + Assert.True(root.GetProperty("symbol_count").GetInt64() >= 1); + Assert.True(root.GetProperty("reference_count").GetInt64() >= 0); + Assert.Equal("test-writer", root.GetProperty("index_writer_version").GetString()); + Assert.Equal("main", root.GetProperty("indexed_head_branch").GetString()); + Assert.Equal("2026-06-11T00:00:00Z", root.GetProperty("indexed_head_timestamp").GetString()); + Assert.Equal(1, root.GetProperty("codeindex_meta_schema_version").GetInt32()); + Assert.Equal(2, root.GetProperty("csharp_symbol_name_contract_version").GetInt32()); + Assert.Equal(1, root.GetProperty("sql_graph_contract_version").GetInt32()); + Assert.Equal(2, root.GetProperty("hotspot_family_version").GetInt32()); + Assert.Equal(2, root.GetProperty("unknown_extension_file_count").GetInt64()); + Assert.False(root.GetProperty("unknown_extension_files_truncated").GetBoolean()); + Assert.Equal(50, root.GetProperty("unknown_extension_file_path_limit").GetInt32()); + Assert.Equal("tools/custom.foo", root.GetProperty("unknown_extension_files")[0].GetString()); + Assert.Equal(JsonValueKind.True, root.GetProperty("graph_ready").ValueKind); + Assert.Equal(JsonValueKind.True, root.GetProperty("issues_ready").ValueKind); + Assert.Equal(JsonValueKind.True, root.GetProperty("fold_ready").ValueKind); + } - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - ReplaceManifestNumber(archivePath, "file_count", 999); - var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath, "--json"]); + var (defaultImportExit, defaultImportStdout, defaultImportStderr) = RunCliInSubprocess([ + "import", pristineArchivePath, "--db", defaultImportDbPath + ]); - Assert.True(exportExit == 0, exportStderr); - Assert.Equal(CommandExitCodes.UsageError, importExit); - Assert.Equal(string.Empty, importStderr); - using var document = JsonDocument.Parse(importStdout); - Assert.Equal("sqlite_validate", document.RootElement.GetProperty("phase").GetString()); - Assert.Equal("import_manifest_mismatch", document.RootElement.GetProperty("error_code").GetString()); - Assert.Contains("file_count", document.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); - Assert.False(File.Exists(importedDbPath)); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } + Assert.True(defaultImportExit == 0, defaultImportStderr); + Assert.Equal(string.Empty, defaultImportStderr); + Assert.Contains("Imported CodeIndex database", defaultImportStdout); + Assert.True(File.Exists(defaultImportDbPath)); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(defaultImportDbPath, out _, out _)); - [ProductionRuntimeFact] - public void ImportArchive_AcceptsOlderManifestWithoutSummaryMetadata_Issue3549() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_old_manifest"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); - - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + File.Copy(pristineArchivePath, legacyArchivePath); RemoveManifestProperties( - archivePath, + legacyArchivePath, "file_count", "chunk_count", "symbol_count", @@ -756,33 +699,52 @@ public void ImportArchive_AcceptsOlderManifestWithoutSummaryMetadata_Issue3549() "unknown_extension_file_count", "unknown_extension_files", "unknown_extension_files_truncated", - "unknown_extension_file_path_limit"); - var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath, "--json"]); + "unknown_extension_file_path_limit", + "unknown_extension_file_sample_count", + "unknown_extension_file_sample_limit", + "unknown_extension_file_sample_truncated"); + var (legacyImportExit, legacyImportStdout, legacyImportStderr) = RunCliInSubprocess([ + "import", legacyArchivePath, "--db", legacyImportDbPath, "--json" + ]); - Assert.True(exportExit == 0, exportStderr); - Assert.Equal(CommandExitCodes.Success, importExit); - Assert.Equal(string.Empty, importStderr); - Assert.True(File.Exists(importedDbPath)); - using var document = JsonDocument.Parse(importStdout); - var root = document.RootElement; - Assert.Equal("1", root.GetProperty("api_version").GetString()); - Assert.Equal("success", root.GetProperty("status").GetString()); - Assert.Equal(Path.GetFullPath(archivePath), root.GetProperty("archive_path").GetString()); - Assert.Equal(Path.GetFullPath(importedDbPath), root.GetProperty("db_path").GetString()); - Assert.Equal("import", root.GetProperty("mode").GetString()); - Assert.False(root.GetProperty("dry_run").GetBoolean()); - var phases = root.GetProperty("validation_phases") - .EnumerateArray() - .ToDictionary( - phase => phase.GetProperty("phase").GetString()!, - phase => phase.GetProperty("status").GetString()!, - StringComparer.Ordinal); - Assert.Equal("success", phases["open_archive"]); - Assert.Equal("success", phases["manifest"]); - Assert.Equal("success", phases["database_entry"]); - Assert.Equal("success", phases["sha256"]); - Assert.Equal("success", phases["sqlite_validate"]); - Assert.Equal("success", phases["replace_db"]); + Assert.True(legacyImportExit == CommandExitCodes.Success, legacyImportStdout); + Assert.Equal(string.Empty, legacyImportStderr); + Assert.True(File.Exists(legacyImportDbPath)); + using (var legacyDocument = JsonDocument.Parse(legacyImportStdout)) + { + var legacyRoot = legacyDocument.RootElement; + Assert.Equal("1", legacyRoot.GetProperty("api_version").GetString()); + Assert.Equal("success", legacyRoot.GetProperty("status").GetString()); + Assert.Equal(Path.GetFullPath(legacyArchivePath), legacyRoot.GetProperty("archive_path").GetString()); + Assert.Equal(Path.GetFullPath(legacyImportDbPath), legacyRoot.GetProperty("db_path").GetString()); + Assert.Equal("import", legacyRoot.GetProperty("mode").GetString()); + Assert.False(legacyRoot.GetProperty("dry_run").GetBoolean()); + var phases = legacyRoot.GetProperty("validation_phases") + .EnumerateArray() + .ToDictionary( + phase => phase.GetProperty("phase").GetString()!, + phase => phase.GetProperty("status").GetString()!, + StringComparer.Ordinal); + Assert.Equal("success", phases["open_archive"]); + Assert.Equal("success", phases["manifest"]); + Assert.Equal("success", phases["database_entry"]); + Assert.Equal("success", phases["sha256"]); + Assert.Equal("success", phases["sqlite_validate"]); + Assert.Equal("success", phases["replace_db"]); + } + + Directory.CreateDirectory(Path.GetDirectoryName(noBackupDbPath)!); + File.WriteAllText(noBackupDbPath, "old"); + File.WriteAllText(noBackupDbPath + "-wal", "old wal"); + File.WriteAllText(noBackupDbPath + "-shm", "old shm"); + var (noBackupImportExit, _, noBackupImportStderr) = RunCliInSubprocess([ + "import", pristineArchivePath, "--db", noBackupDbPath, "--no-backup" + ]); + + Assert.True(noBackupImportExit == 0, noBackupImportStderr); + Assert.False(File.Exists(noBackupDbPath + "-wal")); + Assert.False(File.Exists(noBackupDbPath + "-shm")); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(noBackupDbPath, out _, out _)); } finally { @@ -791,88 +753,137 @@ public void ImportArchive_AcceptsOlderManifestWithoutSummaryMetadata_Issue3549() } [ProductionRuntimeFact] - public void ImportArchive_DryRunJsonValidatesWithoutReplacingDestination_Issue3550() + public void ImportArchive_RejectsCopiedManifestCountHashAndUserVersionMutations_Issue3549() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_dry_run"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_archive_rejections"); + var replacementRoot = TestProjectHelper.CreateTempProject("cdidx_import_hash_replacement"); try { var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var destinationDbPath = Path.Combine(projectRoot, "destination", "codeindex.db"); - Directory.CreateDirectory(Path.GetDirectoryName(destinationDbPath)!); - File.WriteAllText(destinationDbPath, "existing db"); - File.WriteAllText(destinationDbPath + "-wal", "existing wal"); - File.WriteAllText(destinationDbPath + "-shm", "existing shm"); + var replacementDbPath = TestProjectHelper.CreateProjectDb(replacementRoot); + TestProjectHelper.InsertIndexedFile( + replacementDbPath, + "src/other.cs", + "csharp", + "class Other { void Run() {} }\n", + releasePoolForFileAccess: true); + var pristineArchivePath = Path.Combine(projectRoot, "pristine.cdidx.zip"); + var countArchivePath = Path.Combine(projectRoot, "manifest-count.cdidx.zip"); + var hashArchivePath = Path.Combine(projectRoot, "database-hash.cdidx.zip"); + var userVersionArchivePath = Path.Combine(projectRoot, "user-version.cdidx.zip"); + var countDbPath = Path.Combine(projectRoot, "imported-count", "codeindex.db"); + var hashDbPath = Path.Combine(projectRoot, "imported-hash", "codeindex.db"); + var userVersionDbPath = Path.Combine(projectRoot, "imported-user-version", "codeindex.db"); - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - var (dryRunExit, dryRunStdout, dryRunStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--prune-paths", "--no-backup", "--dry-run", "--json" - ]); + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", pristineArchivePath, "--db", sourceDbPath]); Assert.True(exportExit == 0, exportStderr); - Assert.Equal(CommandExitCodes.Success, dryRunExit); - Assert.Equal(string.Empty, dryRunStderr); - Assert.Equal("existing db", File.ReadAllText(destinationDbPath)); - Assert.Equal("existing wal", File.ReadAllText(destinationDbPath + "-wal")); - Assert.Equal("existing shm", File.ReadAllText(destinationDbPath + "-shm")); + File.Copy(pristineArchivePath, countArchivePath); + File.Copy(pristineArchivePath, hashArchivePath); + File.Copy(pristineArchivePath, userVersionArchivePath); - using var document = JsonDocument.Parse(dryRunStdout); - var root = document.RootElement; - Assert.Equal("success", root.GetProperty("status").GetString()); - Assert.Equal("dry_run", root.GetProperty("mode").GetString()); - Assert.True(root.GetProperty("dry_run").GetBoolean()); - Assert.True(root.GetProperty("pruned_paths").GetBoolean()); - Assert.True(root.GetProperty("replacement_would_be_allowed").GetBoolean()); - var phases = root.GetProperty("validation_phases") - .EnumerateArray() - .ToDictionary( - phase => phase.GetProperty("phase").GetString()!, - phase => phase.GetProperty("status").GetString()!, - StringComparer.Ordinal); - Assert.Equal("success", phases["open_archive"]); - Assert.Equal("success", phases["manifest"]); - Assert.Equal("success", phases["database_entry"]); - Assert.Equal("success", phases["sha256"]); - Assert.Equal("success", phases["sqlite_validate"]); - Assert.Equal("success", phases["prune_paths"]); - Assert.Equal("skipped", phases["replace_db"]); + ReplaceManifestNumber(countArchivePath, "file_count", 999); + ReplaceZipEntryWithFile(hashArchivePath, "codeindex.db", replacementDbPath); + ReplaceManifestUserVersion(userVersionArchivePath, newUserVersion: 1); + + var (countExit, countStdout, countStderr) = RunCliInSubprocess([ + "import", countArchivePath, "--db", countDbPath, "--json" + ]); + var (hashExit, _, hashStderr) = RunCliInSubprocess([ + "import", hashArchivePath, "--db", hashDbPath + ]); + var (userVersionExit, _, userVersionStderr) = RunCliInSubprocess([ + "import", userVersionArchivePath, "--db", userVersionDbPath + ]); + + Assert.Equal(CommandExitCodes.UsageError, countExit); + Assert.Equal(string.Empty, countStderr); + using var countDocument = JsonDocument.Parse(countStdout); + Assert.Equal("sqlite_validate", countDocument.RootElement.GetProperty("phase").GetString()); + Assert.Equal("import_manifest_mismatch", countDocument.RootElement.GetProperty("error_code").GetString()); + Assert.Contains("file_count", countDocument.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.False(File.Exists(countDbPath)); + + Assert.Equal(CommandExitCodes.UsageError, hashExit); + Assert.Contains("database_sha256 does not match codeindex.db", hashStderr); + Assert.False(File.Exists(hashDbPath)); + + Assert.Equal(CommandExitCodes.UsageError, userVersionExit); + Assert.Contains("user_version", userVersionStderr); + Assert.False(File.Exists(userVersionDbPath)); } finally { TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(replacementRoot); } } [ProductionRuntimeFact] - public void ImportArchive_CheckJsonDistinguishesCheckMode_Issue4328() + public void ImportArchive_DryRunAndCheckJsonSharePristineExport_Issues3550And4328() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_check_json"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_validation_modes"); try { var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var destinationDbPath = Path.Combine(projectRoot, "destination", "codeindex.db"); - Directory.CreateDirectory(Path.GetDirectoryName(destinationDbPath)!); - File.WriteAllText(destinationDbPath, "existing db"); + var archivePath = Path.Combine(projectRoot, "pristine.cdidx.zip"); + var dryRunDbPath = Path.Combine(projectRoot, "dry-run", "codeindex.db"); + var checkDbPath = Path.Combine(projectRoot, "check", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dryRunDbPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(checkDbPath)!); + File.WriteAllText(dryRunDbPath, "existing dry-run db"); + File.WriteAllText(dryRunDbPath + "-wal", "existing dry-run wal"); + File.WriteAllText(dryRunDbPath + "-shm", "existing dry-run shm"); + File.WriteAllText(checkDbPath, "existing check db"); var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (dryRunExit, dryRunStdout, dryRunStderr) = RunCliInSubprocess([ + "import", archivePath, "--db", dryRunDbPath, "--prune-paths", "--no-backup", "--dry-run", "--json" + ]); var (checkExit, checkStdout, checkStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--no-backup", "--check", "--json" + "import", archivePath, "--db", checkDbPath, "--no-backup", "--check", "--json" ]); Assert.True(exportExit == 0, exportStderr); + Assert.Equal(CommandExitCodes.Success, dryRunExit); + Assert.Equal(string.Empty, dryRunStderr); + Assert.Equal("existing dry-run db", File.ReadAllText(dryRunDbPath)); + Assert.Equal("existing dry-run wal", File.ReadAllText(dryRunDbPath + "-wal")); + Assert.Equal("existing dry-run shm", File.ReadAllText(dryRunDbPath + "-shm")); + + using var dryRunDocument = JsonDocument.Parse(dryRunStdout); + var dryRunRoot = dryRunDocument.RootElement; + Assert.Equal("success", dryRunRoot.GetProperty("status").GetString()); + Assert.Equal("dry_run", dryRunRoot.GetProperty("mode").GetString()); + Assert.True(dryRunRoot.GetProperty("dry_run").GetBoolean()); + Assert.True(dryRunRoot.GetProperty("pruned_paths").GetBoolean()); + Assert.True(dryRunRoot.GetProperty("replacement_would_be_allowed").GetBoolean()); + var phases = dryRunRoot.GetProperty("validation_phases") + .EnumerateArray() + .ToDictionary( + phase => phase.GetProperty("phase").GetString()!, + phase => phase.GetProperty("status").GetString()!, + StringComparer.Ordinal); + Assert.Equal("success", phases["open_archive"]); + Assert.Equal("success", phases["manifest"]); + Assert.Equal("success", phases["database_entry"]); + Assert.Equal("success", phases["sha256"]); + Assert.Equal("success", phases["sqlite_validate"]); + Assert.Equal("success", phases["prune_paths"]); + Assert.Equal("skipped", phases["replace_db"]); + Assert.Equal(CommandExitCodes.Success, checkExit); Assert.Equal(string.Empty, checkStderr); - Assert.Equal("existing db", File.ReadAllText(destinationDbPath)); - - using var document = JsonDocument.Parse(checkStdout); - var root = document.RootElement; - Assert.Equal("success", root.GetProperty("status").GetString()); - Assert.Equal("check", root.GetProperty("mode").GetString()); - Assert.True(root.GetProperty("dry_run").GetBoolean()); - var replaceDbPhase = root.GetProperty("validation_phases") + Assert.Equal("existing check db", File.ReadAllText(checkDbPath)); + + using var checkDocument = JsonDocument.Parse(checkStdout); + var checkRoot = checkDocument.RootElement; + Assert.Equal("success", checkRoot.GetProperty("status").GetString()); + Assert.Equal("check", checkRoot.GetProperty("mode").GetString()); + Assert.True(checkRoot.GetProperty("dry_run").GetBoolean()); + var replaceDbPhase = checkRoot.GetProperty("validation_phases") .EnumerateArray() .Single(phase => phase.GetProperty("phase").GetString() == "replace_db"); Assert.Equal("skipped", replaceDbPhase.GetProperty("status").GetString()); @@ -915,67 +926,6 @@ public void ImportArchive_InvalidArchiveJsonReportsRootCause_Issue4328() } } - [ProductionRuntimeFact] - public void ImportArchive_RejectsDatabaseHashMismatch() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_hash_mismatch"); - var replacementRoot = TestProjectHelper.CreateTempProject("cdidx_import_hash_replacement"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var replacementDbPath = TestProjectHelper.CreateProjectDb(replacementRoot); - TestProjectHelper.InsertIndexedFile( - replacementDbPath, - "src/other.cs", - "csharp", - "class Other { void Run() {} }\n", - releasePoolForFileAccess: true); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); - - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - ReplaceZipEntryWithFile(archivePath, "codeindex.db", replacementDbPath); - var (importExit, _, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath]); - - Assert.True(exportExit == 0, exportStderr); - Assert.Equal(CommandExitCodes.UsageError, importExit); - Assert.Contains("database_sha256 does not match codeindex.db", importStderr); - Assert.False(File.Exists(importedDbPath)); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - TestProjectHelper.DeleteDirectory(replacementRoot); - } - } - - [ProductionRuntimeFact] - public void ImportArchive_RejectsManifestUserVersionMismatch() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_user_version_mismatch"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); - - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - ReplaceManifestUserVersion(archivePath, newUserVersion: 1); - var (importExit, _, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath]); - - Assert.True(exportExit == 0, exportStderr); - Assert.Equal(CommandExitCodes.UsageError, importExit); - Assert.Contains("user_version", importStderr); - Assert.False(File.Exists(importedDbPath)); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [ProductionRuntimeFact] public void ExportArchive_RejectsSourceDatabaseAsOutput() { @@ -997,38 +947,6 @@ public void ExportArchive_RejectsSourceDatabaseAsOutput() } } - [ProductionRuntimeFact] - public void ImportArchive_RemovesStaleDestinationSidecars() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_sidecars"); - try - { - var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); - var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); - var destinationDbPath = Path.Combine(projectRoot, "destination", "codeindex.db"); - Directory.CreateDirectory(Path.GetDirectoryName(destinationDbPath)!); - File.WriteAllText(destinationDbPath, "old"); - File.WriteAllText(destinationDbPath + "-wal", "old wal"); - File.WriteAllText(destinationDbPath + "-shm", "old shm"); - - var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - var (importExit, _, importStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--no-backup" - ]); - - Assert.True(exportExit == 0, exportStderr); - Assert.True(importExit == 0, importStderr); - Assert.False(File.Exists(destinationDbPath + "-wal")); - Assert.False(File.Exists(destinationDbPath + "-shm")); - Assert.True(DbContext.TryValidateExistingCodeIndexDb(destinationDbPath, out _, out _)); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [ProductionRuntimeFact] public void Doctor_PrintsRedactedEnvironmentSummary() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index fd39828b8..e35688dad 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8283,53 +8283,57 @@ public void RunSearch_NdjsonByteCapReportsPartialOrRejectsUndersizedTerminal_Iss } } - [Theory] - [InlineData(1, 1)] - [InlineData(20_000_000, 16 * 1024 * 1024)] - public void WriteJsonObject_ResponseAboveEffectiveMaximumRequiresSizeReduction_Issue4909( - int requestedMaxJsonBytes, - int expectedEffectiveBytes) + [Fact] + public void WriteJsonObject_ResponseAboveEffectiveMaximumRequiresSizeReduction_Issue4909() { - var options = QueryCommandRunner.ParseArgs( - [ - "needle", - "--json=array", - "--max-json-bytes", - requestedMaxJsonBytes.ToString(System.Globalization.CultureInfo.InvariantCulture), - ], - jsonDefault: false); - Assert.Equal(requestedMaxJsonBytes, options.RequestedMaxJsonBytes); - Assert.Equal(expectedEffectiveBytes, options.MaxJsonBytes); var oversizedJson = JsonSerializer.Serialize(new { payload = new string('x', QueryCommandRunner.MaxSearchJsonByteLimit + 1), }); - var (exitCode, stdout, stderr) = CaptureConsole(() => - QueryCommandRunner.WriteJsonObjectWithOptionalByteLimit( - oversizedJson, - options, - "oversized test payload", - "Reduce the test payload.", - _jsonOptions, - "search")); + foreach (var testCase in new[] + { + (RequestedMaxJsonBytes: 1, ExpectedEffectiveBytes: 1), + (RequestedMaxJsonBytes: 20_000_000, ExpectedEffectiveBytes: QueryCommandRunner.MaxSearchJsonByteLimit), + }) + { + var options = QueryCommandRunner.ParseArgs( + [ + "needle", + "--json=array", + "--max-json-bytes", + testCase.RequestedMaxJsonBytes.ToString(System.Globalization.CultureInfo.InvariantCulture), + ], + jsonDefault: false); + Assert.Equal(testCase.RequestedMaxJsonBytes, options.RequestedMaxJsonBytes); + Assert.Equal(testCase.ExpectedEffectiveBytes, options.MaxJsonBytes); - Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stderr); - using var document = ParseJsonOutput(stdout); - var error = document.RootElement; - Assert.Equal(requestedMaxJsonBytes, error.GetProperty("requested_bytes").GetInt64()); - Assert.Equal(expectedEffectiveBytes, error.GetProperty("effective_bytes").GetInt64()); - Assert.True( - error.GetProperty("minimum_required_bytes").GetInt64() - > QueryCommandRunner.MaxSearchJsonByteLimit); - var retry = error.GetProperty("retry"); - Assert.Equal("reduce_response_size", retry.GetProperty("action").GetString()); - Assert.Equal(JsonValueKind.Null, retry.GetProperty("option").ValueKind); - Assert.Equal(JsonValueKind.Null, retry.GetProperty("recommended_bytes").ValueKind); - Assert.Equal( - QueryCommandRunner.MaxSearchJsonByteLimit, - retry.GetProperty("maximum_effective_bytes").GetInt64()); + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.WriteJsonObjectWithOptionalByteLimit( + oversizedJson, + options, + "oversized test payload", + "Reduce the test payload.", + _jsonOptions, + "search")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var error = document.RootElement; + Assert.Equal(testCase.RequestedMaxJsonBytes, error.GetProperty("requested_bytes").GetInt64()); + Assert.Equal(testCase.ExpectedEffectiveBytes, error.GetProperty("effective_bytes").GetInt64()); + Assert.True( + error.GetProperty("minimum_required_bytes").GetInt64() + > QueryCommandRunner.MaxSearchJsonByteLimit); + var retry = error.GetProperty("retry"); + Assert.Equal("reduce_response_size", retry.GetProperty("action").GetString()); + Assert.Equal(JsonValueKind.Null, retry.GetProperty("option").ValueKind); + Assert.Equal(JsonValueKind.Null, retry.GetProperty("recommended_bytes").ValueKind); + Assert.Equal( + QueryCommandRunner.MaxSearchJsonByteLimit, + retry.GetProperty("maximum_effective_bytes").GetInt64()); + } } [Fact] @@ -12749,298 +12753,59 @@ public void RunSearch_RecognizesMsbuildProjectFiles() } [Fact] - public void RunSearch_RecognizesXamlLanguageAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_xaml_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = $"xaml_lang_alias_{Guid.NewGuid():N}"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/MainWindow.xaml", - "xml", - $$""" - - - - - - """); - - foreach (var lang in new[] { "xaml", "axaml" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", lang, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_RecognizesRustLanguageAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_rust_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = $"rust_lang_alias_{Guid.NewGuid():N}"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/lib.rs", - "rust", - $$""" - pub fn hit() { - let _ = "{{queryToken}}"; - } - """); - - foreach (var lang in new[] { "rs", "r-s", "r s" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", lang, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_NormalizesCommonLanguageAliases() + public void RunSearch_NormalizesLanguageAliasesAcrossSharedIndex() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = "lang_alias_91d4b3"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.cs", - "csharp", - $@"public class App -{{ - public void Run() - {{ - var marker = ""{queryToken}""; - }} -}}"); - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.kt", - "kotlin", - $@"class App {{ - fun run() {{ - val marker = ""{queryToken}"" - }} -}}"); - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.java", - "java", - $@"class App {{ - void run() {{ - String marker = ""{queryToken}""; - }} -}}"); - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.js", - "javascript", - $@"function run() {{ - const marker = ""{queryToken}""; -}}"); - - foreach (var input in new[] { "c#", "cs", "cshtml", "js", "JSX", "cjs", "MJS", "Java", "kt", "kts", "razor" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", input, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_NormalizesJavascriptLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_javascript_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = $"javascript_lang_alias_{Guid.NewGuid():N}"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.js", - "javascript", - $@"const marker = ""{queryToken}"";"); - - foreach (var lang in new[] { "js", "jsx", "JS", "JSX" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", lang, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_NormalizesJavascriptExtensionStyleLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_javascript_extension_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = $"javascript_extension_lang_alias_{Guid.NewGuid():N}"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/App.mjs", - "javascript", - $@"const marker = ""{queryToken}"";"); - - foreach (var lang in new[] { "cjs", "mjs", "CJS", "MJS" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", lang, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_NormalizesYamlLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_yaml_lang_alias"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = "yaml_lang_alias_3d5a19"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "config/workflow.yml", - "yaml", - $@"name: demo -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo ""{queryToken}"""); - - foreach (var input in new[] { "yml", "YML" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", input, "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - - [Fact] - public void RunSearch_NormalizesBatchLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_batch_lang_alias"); - try + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_query_runner_language_aliases"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + var markerSuffix = Guid.NewGuid().ToString("N"); + var sharedQuery = $"shared_language_alias_{markerSuffix}"; + var cases = new[] { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = "batch_lang_alias_7a24d1"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "scripts/run.bat", - "batch", - $"echo {queryToken}\r\n"); - - foreach (var input in new[] { "bat", "cmd" }) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", input, "--count"], - _jsonOptions)); + (Path: "src/MainWindow.xaml", Lang: "xml", Query: $"xaml_alias_{markerSuffix}", Exact: false, + Aliases: new[] { "xaml", "axaml" }), + (Path: "src/lib.rs", Lang: "rust", Query: $"rust_alias_{markerSuffix}", Exact: false, + Aliases: new[] { "rs", "r-s", "r s" }), + (Path: "src/App.cs", Lang: "csharp", Query: sharedQuery, Exact: false, + Aliases: new[] { "c#", "cs", "cshtml", "razor" }), + (Path: "src/App.kt", Lang: "kotlin", Query: sharedQuery, Exact: false, + Aliases: new[] { "kt", "kts" }), + (Path: "src/App.java", Lang: "java", Query: sharedQuery, Exact: false, + Aliases: new[] { "Java" }), + (Path: "src/App.js", Lang: "javascript", Query: sharedQuery, Exact: false, + Aliases: new[] { "js", "jsx", "JS", "JSX", "cjs", "mjs", "CJS", "MJS" }), + (Path: "config/workflow.yml", Lang: "yaml", Query: $"yaml_alias_{markerSuffix}", Exact: false, + Aliases: new[] { "yml", "YML" }), + (Path: "scripts/run.bat", Lang: "batch", Query: $"batch_alias_{markerSuffix}", Exact: false, + Aliases: new[] { "bat", "cmd" }), + (Path: "sql/repro.sql", Lang: "sql", Query: $"sql_alias_{markerSuffix}", Exact: false, + Aliases: new[] { "T-SQL", "transact-sql", "transact sql" }), + (Path: "package/example.rb", Lang: "ruby", Query: "public_api", Exact: true, + Aliases: new[] { "rb" }), + (Path: "Module.fs", Lang: "fsharp", Query: "public_api", Exact: true, + Aliases: new[] { "fs" }), + }; - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } + foreach (var testCase in cases) + TestProjectHelper.InsertIndexedFile(dbPath, testCase.Path, testCase.Lang, testCase.Query); - [Fact] - public void RunSearch_NormalizesSqlDialectLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_sql_lang_alias"); - try + foreach (var testCase in cases) { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var queryToken = "sql_lang_alias_3f7d21"; - TestProjectHelper.InsertIndexedFile( - dbPath, - "sql/repro.sql", - "sql", - $"SELECT '{queryToken}';"); - - foreach (var input in new[] { "T-SQL", "transact-sql", "transact sql" }) + foreach (var alias in testCase.Aliases) { + string[] args = testCase.Exact + ? [testCase.Query, "--db", dbPath, "--lang", alias, "--exact", "--json=array"] + : [testCase.Query, "--db", dbPath, "--lang", alias, "--json=array"]; var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", input, "--count"], + args, _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var result = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal(testCase.Path, result.GetProperty("path").GetString()); } } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } } [Fact] @@ -13182,38 +12947,6 @@ public void RunSearch_TrailingWildcardActsAsPrefixShorthand() } } - [Fact] - public void RunSearch_AcceptsRubyAndFsharpLangAliases() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_ruby_fsharp_lang_aliases"); - try - { - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - var cases = new[] - { - (Alias: "rb", CanonicalLang: "ruby", FilePath: "package/example.rb"), - (Alias: "fs", CanonicalLang: "fsharp", FilePath: "Module.fs"), - }; - foreach (var testCase in cases) - TestProjectHelper.InsertIndexedFile(dbPath, testCase.FilePath, testCase.CanonicalLang, "public_api\n"); - - foreach (var testCase in cases) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["public_api", "--db", dbPath, "--lang", testCase.Alias, "--exact", "--count"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("1", stdout.Trim()); - Assert.Equal(string.Empty, stderr); - } - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [Fact] public void RunSearch_ZeroResultsHumanOutputIncludesQueryFilterContext() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index c5fa46ab6..56a1e476a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -2308,147 +2308,27 @@ public void BuildUnusedResultsByBucketJson_UsesCompleteCanonicalMembershipIndexe } [Fact] - public void RunUnused_MaxJsonBytesPagesCanonicalRowsWithoutGaps_Issue4904() + public void RunUnused_MaxJsonBytesRejectsInvalidOptionCombinations_Issue4904() { - var (projectRoot, dbPath) = CreateUnusedFixtureDb(); - try + var cases = new[] { - var (unboundedExitCode, unboundedStdout, unboundedStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--all", "--lang", "csharp", "--by-bucket", "--limit", "100"], - _jsonOptions)); - using var unboundedDocument = ParseJsonOutput(unboundedStdout); - var expectedNames = unboundedDocument.RootElement - .GetProperty("symbols") - .EnumerateArray() - .Select(symbol => symbol.GetProperty("name").GetString()) - .ToArray(); - Assert.Equal(CommandExitCodes.Success, unboundedExitCode); - Assert.Equal(string.Empty, unboundedStderr); - Assert.True(expectedNames.Length > 1); - - var byteBudget = Encoding.UTF8.GetByteCount(unboundedStdout) - 1; - - var returnedNames = new List(); - string? cursor = null; - var sawTruncatedPage = false; - do - { - var args = new List - { - "--db", dbPath, - "--json", - "--all", - "--lang", "csharp", - "--by-bucket", - "--limit", "100", - "--max-json-bytes", byteBudget.ToString(), - }; - if (cursor != null) - { - args.Add("--cursor"); - args.Add(cursor); - } - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - args.ToArray(), - _jsonOptions)); - using var document = ParseJsonOutput(stdout); - var json = document.RootElement; - var pageNames = json.GetProperty("symbols") - .EnumerateArray() - .Select(symbol => symbol.GetProperty("name").GetString()) - .ToArray(); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.NotEmpty(pageNames); - Assert.True(Encoding.UTF8.GetByteCount(stdout) <= byteBudget); - Assert.Equal(byteBudget, json.GetProperty("output_byte_limit").GetInt32()); - Assert.Equal(pageNames.Length, json.GetProperty("count").GetInt32()); - sawTruncatedPage |= json.GetProperty("truncated").GetBoolean(); - returnedNames.AddRange(pageNames); - cursor = json.TryGetProperty("next_cursor", out var cursorElement) - ? cursorElement.GetString() - : null; - } - while (cursor != null); - - Assert.True(sawTruncatedPage); - Assert.Equal(expectedNames, returnedNames); - - var (unboundedCompactExitCode, unboundedCompactStdout, unboundedCompactStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--compact", "--all", "--lang", "csharp", "--by-bucket"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, unboundedCompactExitCode); - Assert.Equal(string.Empty, unboundedCompactStderr); - var compactByteBudget = Encoding.UTF8.GetByteCount(unboundedCompactStdout) + 128; - var (compactExitCode, compactStdout, compactStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--compact", "--all", "--lang", "csharp", "--by-bucket", "--max-json-bytes", compactByteBudget.ToString()], - _jsonOptions)); - using var compactDocument = ParseJsonOutput(compactStdout); - Assert.Equal(CommandExitCodes.Success, compactExitCode); - Assert.Equal(string.Empty, compactStderr); - Assert.True(Encoding.UTF8.GetByteCount(compactStdout) <= compactByteBudget); - Assert.False(compactDocument.RootElement.TryGetProperty("symbols", out _)); - - var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--count", "--all", "--max-json-bytes", "65536"], - _jsonOptions)); - using var countDocument = ParseJsonOutput(countStdout); - Assert.Equal(CommandExitCodes.Success, countExitCode); - Assert.Equal(string.Empty, countStderr); - Assert.Equal(65536, countDocument.RootElement.GetProperty("output_byte_limit").GetInt32()); - Assert.False(countDocument.RootElement.GetProperty("truncated").GetBoolean()); - Assert.Equal(0, countDocument.RootElement.GetProperty("omitted_count").GetInt32()); + (Args: new[] { "--max-json-bytes", "1000" }, ExpectedMessage: "only supported with unused JSON output"), + (Args: new[] { "--json", "--count", "--max-json-bytes", "3000", "--verbose" }, ExpectedMessage: "cannot be combined with --profile or --verbose"), + (Args: new[] { "--json", "--count", "--max-json-bytes", "3000", "--profile" }, ExpectedMessage: "cannot be combined with --profile or --verbose"), + }; - var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--lang", "rust", "--max-json-bytes", "65536"], + foreach (var testCase in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + testCase.Args, _jsonOptions)); - using var emptyDocument = ParseJsonOutput(emptyStdout); - Assert.Equal(CommandExitCodes.Success, emptyExitCode); - Assert.Equal(string.Empty, emptyStderr); - Assert.Equal(0, emptyDocument.RootElement.GetProperty("count").GetInt32()); - Assert.Empty(emptyDocument.RootElement.GetProperty("symbols").EnumerateArray()); - var (smallExitCode, smallStdout, smallStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--all", "--max-json-bytes", "1"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.UsageError, smallExitCode); - Assert.Equal(string.Empty, smallStdout); - Assert.Contains("one canonical symbol row", smallStderr); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(testCase.ExpectedMessage, stderr, StringComparison.Ordinal); } } - [Fact] - public void RunUnused_MaxJsonBytesWithoutJsonReturnsUsageError_Issue4904() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--max-json-bytes", "1000"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains("only supported with unused JSON output", stderr); - } - - [Theory] - [InlineData("--verbose")] - [InlineData("--profile")] - public void RunUnused_MaxJsonBytesRejectsSeparateJsonDiagnostics_Issue4904(string diagnosticsOption) - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--json", "--count", "--max-json-bytes", "3000", diagnosticsOption], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains("cannot be combined with --profile or --verbose", stderr); - } - [Fact] public void RunUnused_MaxJsonBytesCapsInvalidDatabaseJson_Issue4904() { @@ -2482,24 +2362,18 @@ public void RunUnused_MaxJsonBytesCapsInvalidDatabaseJson_Issue4904() [Fact] public void RunUnused_MaxJsonBytesCapsDatabaseCodeIndexExceptionJson_Issue4904() { - var (projectRoot, dbPath) = CreateUnusedFixtureDb(); - try - { - using var env = EnvironmentVariableScope.Capture(DatabasePermissionPolicy.EnvironmentVariable); - env.Set(DatabasePermissionPolicy.EnvironmentVariable, "invalid"); + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_issue4904_permission_budget"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + using var env = EnvironmentVariableScope.Capture(DatabasePermissionPolicy.EnvironmentVariable); + env.Set(DatabasePermissionPolicy.EnvironmentVariable, "invalid"); - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--all", "--max-json-bytes", "1"], - _jsonOptions)); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--all", "--max-json-bytes", "1"], + _jsonOptions)); - Assert.Equal(CommandExitCodes.DatabaseError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains("Invalid CDIDX_DB_PERMISSION_POLICY value", stderr); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("Invalid CDIDX_DB_PERMISSION_POLICY value", stderr); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 9a94e3c60..3cb2b0231 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1846,113 +1846,30 @@ public void LanguageValidationEscapeAppearsInSearchHelpAndCompletions_Issue4842( } [Fact] - public void GetLanguageAliases_ReportsSqlDialectAliases() + public void GetLanguageAliases_ReportsRegisteredAliases() { - var aliases = QueryCommandRunner.GetLanguageAliases("sql"); - - Assert.Contains("tsql", aliases); - Assert.Contains("t-sql", aliases); - Assert.Contains("transact-sql", aliases); - Assert.Contains("transactsql", aliases); - Assert.Contains("sqlserver", aliases); - Assert.Contains("mssql", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsRazorBlazorAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("csharp"); - - Assert.Contains("cshtml", aliases); - Assert.Contains("razor", aliases); - Assert.Contains("blazor", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsTypeScriptAlias() - { - var aliases = QueryCommandRunner.GetLanguageAliases("typescript"); - - Assert.Contains("ts", aliases); - Assert.Contains("tsx", aliases); - Assert.Contains("cts", aliases); - Assert.Contains("mts", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsRustAlias() - { - var aliases = QueryCommandRunner.GetLanguageAliases("rust"); - - Assert.Contains("rs", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsJavaAlias() - { - var aliases = QueryCommandRunner.GetLanguageAliases("java"); - - Assert.Contains("jav", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsAssemblyAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("assembly"); - - Assert.Contains("asm", aliases); - Assert.Contains("assembler", aliases); - Assert.Contains("nasm", aliases); - Assert.Contains("gas", aliases); - Assert.Contains("gnuasm", aliases); - Assert.Contains("gnu assembler", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsFsharpAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("fsharp"); - - Assert.Contains("f#", aliases); - Assert.Contains("fs", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsJavascriptAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("javascript"); - - Assert.Contains("js", aliases); - Assert.Contains("jsx", aliases); - Assert.Contains("cjs", aliases); - Assert.Contains("mjs", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsXmlAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("xml"); - - Assert.Contains("xaml", aliases); - Assert.Contains("axaml", aliases); - } - - [Fact] - public void GetLanguageAliases_ReportsPythonAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("python"); - - Assert.Contains("py", aliases); - Assert.Contains("py3", aliases); - Assert.Contains("python3", aliases); - } + (string Language, string[] ExpectedAliases)[] cases = + [ + ("sql", ["tsql", "t-sql", "transact-sql", "transactsql", "sqlserver", "mssql"]), + ("csharp", ["cshtml", "razor", "blazor"]), + ("typescript", ["ts", "tsx", "cts", "mts"]), + ("rust", ["rs"]), + ("java", ["jav"]), + ("assembly", ["asm", "assembler", "nasm", "gas", "gnuasm", "gnu assembler"]), + ("fsharp", ["f#", "fs"]), + ("javascript", ["js", "jsx", "cjs", "mjs"]), + ("xml", ["xaml", "axaml"]), + ("python", ["py", "py3", "python3"]), + ("ruby", ["rb"]), + ]; - [Fact] - public void GetLanguageAliases_ReportsRubyAliases() - { - var aliases = QueryCommandRunner.GetLanguageAliases("ruby"); + foreach (var (language, expectedAliases) in cases) + { + var aliases = QueryCommandRunner.GetLanguageAliases(language); - Assert.Contains("rb", aliases); + foreach (var expectedAlias in expectedAliases) + Assert.Contains(expectedAlias, aliases); + } } [Theory] @@ -3496,31 +3413,6 @@ public void RunLanguages_MissingCapabilityReturnsUsageError() Assert.Contains($"Usage: {ConsoleUi.GetUsageLine("languages")}", stderr); } - [Fact] - public void RunLanguages_JsonListsModernNodeModuleExtensions() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var javascript = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "javascript"); - var typescript = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "typescript"); - var objc = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "objc"); - var ambiguousM = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "ambiguous_m"); - - Assert.Contains(".cjs", javascript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - Assert.Contains(".mjs", javascript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - Assert.Contains("js", javascript.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString())); - Assert.Contains("jsx", javascript.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString())); - Assert.Contains(".cts", typescript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - Assert.Contains(".mts", typescript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - Assert.Contains(".mm", objc.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - Assert.Contains(".m", ambiguousM.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - } - [Fact] public void RunLanguages_AmbiguousUppercaseExtensionExplainsCandidatesAndOverrides_Issue4901() { @@ -3671,95 +3563,6 @@ public void RunLanguages_SeparatorNormalizedAmbiguousExtensionKeepsDiagnostics_I .Select(candidate => candidate.GetProperty("lang").GetString())); } - [Fact] - public void RunLanguages_JsonReportsCythonAndCudaReferences_Issues4737And4738() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var cython = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == "cython"); - var cuda = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == "cuda"); - - Assert.True(cython.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(cython.GetProperty("reference_extraction").GetBoolean()); - Assert.True(cython.GetProperty("graph_queries").GetBoolean()); - Assert.True(cuda.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(cuda.GetProperty("reference_extraction").GetBoolean()); - Assert.True(cuda.GetProperty("graph_queries").GetBoolean()); - Assert.Empty(cuda.GetProperty("capability_gaps").EnumerateArray()); - Assert.Empty(cuda.GetProperty("unsupported_guidance").EnumerateArray()); - } - - [Fact] - public void RunLanguages_JsonReportsHdlGraphExtraction_Issue3532_Issue4742() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - foreach (var language in new[] { "verilog", "systemverilog", "vhdl" }) - { - var entry = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == language); - Assert.True(entry.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(entry.GetProperty("reference_extraction").GetBoolean()); - Assert.True(entry.GetProperty("graph_queries").GetBoolean()); - } - } - - [Fact] - public void RunLanguages_JsonReportsShaderReferenceExtraction_Issue4737() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - foreach (var language in new[] { "glsl", "hlsl", "metal", "wgsl" }) - { - var entry = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == language); - Assert.True(entry.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(entry.GetProperty("reference_extraction").GetBoolean()); - Assert.True(entry.GetProperty("graph_queries").GetBoolean()); - Assert.Empty(entry.GetProperty("capability_gaps").EnumerateArray()); - Assert.Empty(entry.GetProperty("unsupported_guidance").EnumerateArray()); - } - } - - [Fact] - public void RunLanguages_JsonReportsDependencyPackageSymbolExtraction_Issue3899() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var manifest = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == "dependency_manifest"); - var lockfile = languages.EnumerateArray().Single(lang => lang.GetProperty("lang").GetString() == "dependency_lock"); - - Assert.True(manifest.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(manifest.GetProperty("reference_extraction").GetBoolean()); - Assert.True(manifest.GetProperty("graph_queries").GetBoolean()); - Assert.DoesNotContain("missing-symbols", manifest.GetProperty("capability_gaps").EnumerateArray().Select(gap => gap.GetString())); - Assert.Contains("Directory.Packages.props", manifest.GetProperty("exact_filenames").EnumerateArray().Select(value => value.GetString())); - - Assert.True(lockfile.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(lockfile.GetProperty("reference_extraction").GetBoolean()); - Assert.True(lockfile.GetProperty("graph_queries").GetBoolean()); - Assert.DoesNotContain("missing-symbols", lockfile.GetProperty("capability_gaps").EnumerateArray().Select(gap => gap.GetString())); - Assert.Contains("packages.lock.json", lockfile.GetProperty("exact_filenames").EnumerateArray().Select(value => value.GetString())); - } - [Fact] public void RunLanguages_JsonReportsFilesystemFilenameCasePolicy_Issue4601() { @@ -3965,34 +3768,6 @@ public void RunLanguages_JsonReportsLanguageMapOverrideProvenance_Issue4617() } } - [Fact] - public void RunLanguages_JsonReportsScientificNativeAndPrologReferenceCapabilities_Issues4738And4746() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages").EnumerateArray() - .ToDictionary(entry => entry.GetProperty("lang").GetString()!, entry => entry); - foreach (var language in new[] { "ada", "ambiguous_m", "cython", "d", "julia", "matlab", "nim" }) - { - Assert.True(languages[language].GetProperty("symbol_extraction").GetBoolean()); - Assert.True(languages[language].GetProperty("reference_extraction").GetBoolean()); - Assert.True(languages[language].GetProperty("graph_queries").GetBoolean()); - } - - foreach (var language in new[] { "prolog", "ambiguous_pl" }) - { - Assert.True(languages[language].GetProperty("symbol_extraction").GetBoolean()); - Assert.True(languages[language].GetProperty("reference_extraction").GetBoolean()); - Assert.True(languages[language].GetProperty("graph_queries").GetBoolean()); - } - Assert.Contains(".m", languages["ambiguous_m"].GetProperty("extensions").EnumerateArray().Select(value => value.GetString())); - Assert.Contains(".pl", languages["ambiguous_pl"].GetProperty("extensions").EnumerateArray().Select(value => value.GetString())); - } - [Fact] public void RunSymbolsAndReferences_AcceptDependencyPackageKinds_Issue3899() { @@ -4034,92 +3809,130 @@ public void RunSymbolsAndReferences_AcceptDependencyPackageKinds_Issue3899() } [Fact] - public void RunLanguages_JsonListsHtmlWithSymbolExtractionAndAllExtensions() - { - // Pin the #215 surface: `cdidx languages --json` must report html with - // symbol_extraction/reference_extraction=true and list all four extensions - // (.html, .htm, .xhtml, .shtml) so AI tools can discover HTML support without indexing first. - // #215 の表面契約を pin: `cdidx languages --json` は html を symbol_extraction / - // reference_extraction=true で返し、`.html` / `.htm` / `.xhtml` / `.shtml` の 4 拡張子を - // 列挙する必要がある。AI ツールがインデックス前でも HTML 対応を検出できるようにするため。 - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var html = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "html"); - - Assert.True(html.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(html.GetProperty("reference_extraction").GetBoolean()); - var extensions = html.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString()).ToList(); - Assert.Contains(".html", extensions); - Assert.Contains(".htm", extensions); - Assert.Contains(".xhtml", extensions); - Assert.Contains(".shtml", extensions); - } - - [Fact] - public void RunLanguages_JsonListsAssemblyWithSymbolExtractionGraphAndAliases() + public void RunLanguages_JsonCatalogReportsExtensionsAliasesAndExtractionCapabilities() { + // Build and parse the unfiltered catalog once so the language-specific contracts below + // stay directly comparable without repeating the same discovery and serialization work. + // Every extractor bucket must advertise the graph support implemented by its + // dedicated reference extractor (#4743). + // 各 extractor bucket は専用 reference extractor の実装どおりに graph 対応を広告する。 var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var assembly = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "assembly"); + var languages = document.RootElement.GetProperty("languages").EnumerateArray() + .ToDictionary(entry => entry.GetProperty("lang").GetString()!, entry => entry); - Assert.True(assembly.GetProperty("symbol_extraction").GetBoolean()); - Assert.True(assembly.GetProperty("reference_extraction").GetBoolean()); - Assert.True(assembly.GetProperty("graph_queries").GetBoolean()); + var javascript = languages["javascript"]; + var typescript = languages["typescript"]; + var objc = languages["objc"]; + var ambiguousM = languages["ambiguous_m"]; + Assert.Contains(".cjs", javascript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); + Assert.Contains(".mjs", javascript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); + Assert.Contains("js", javascript.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString())); + Assert.Contains("jsx", javascript.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString())); + Assert.Contains(".cts", typescript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); + Assert.Contains(".mts", typescript.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); + Assert.Contains(".mm", objc.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); + Assert.Contains(".m", ambiguousM.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString())); - var extensions = assembly.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString()).ToList(); - Assert.Contains(".s", extensions); - Assert.Contains(".S", extensions); - Assert.Contains(".asm", extensions); - Assert.Contains(".nasm", extensions); + // Cython and CUDA reference support (#4737, #4738). + var cython = languages["cython"]; + var cuda = languages["cuda"]; + Assert.True(cython.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(cython.GetProperty("reference_extraction").GetBoolean()); + Assert.True(cython.GetProperty("graph_queries").GetBoolean()); + Assert.True(cuda.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(cuda.GetProperty("reference_extraction").GetBoolean()); + Assert.True(cuda.GetProperty("graph_queries").GetBoolean()); + Assert.Empty(cuda.GetProperty("capability_gaps").EnumerateArray()); + Assert.Empty(cuda.GetProperty("unsupported_guidance").EnumerateArray()); - var aliases = assembly.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString()).ToList(); - Assert.Contains("asm", aliases); - Assert.Contains("assembler", aliases); - Assert.Contains("gas", aliases); - Assert.Contains("gnuasm", aliases); - Assert.Contains("gnu assembler", aliases); - } + // HDL graph extraction (#3532, #4742). + foreach (var language in new[] { "verilog", "systemverilog", "vhdl" }) + { + var entry = languages[language]; + Assert.True(entry.GetProperty("symbol_extraction").GetBoolean(), $"{language} must advertise symbol extraction"); + Assert.True(entry.GetProperty("reference_extraction").GetBoolean(), $"{language} must advertise reference extraction"); + Assert.True(entry.GetProperty("graph_queries").GetBoolean(), $"{language} must advertise graph queries"); + } - [Fact] - public void RunLanguages_JsonListsCSharpRazorAliases() - { - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); + // Shader reference extraction (#4737). + foreach (var language in new[] { "glsl", "hlsl", "metal", "wgsl" }) + { + var entry = languages[language]; + Assert.True(entry.GetProperty("symbol_extraction").GetBoolean(), $"{language} must advertise symbol extraction"); + Assert.True(entry.GetProperty("reference_extraction").GetBoolean(), $"{language} must advertise reference extraction"); + Assert.True(entry.GetProperty("graph_queries").GetBoolean(), $"{language} must advertise graph queries"); + Assert.Empty(entry.GetProperty("capability_gaps").EnumerateArray()); + Assert.Empty(entry.GetProperty("unsupported_guidance").EnumerateArray()); + } - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); + // Dependency package symbols and references (#3899). + var manifest = languages["dependency_manifest"]; + var lockfile = languages["dependency_lock"]; + Assert.True(manifest.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(manifest.GetProperty("reference_extraction").GetBoolean()); + Assert.True(manifest.GetProperty("graph_queries").GetBoolean()); + Assert.DoesNotContain("missing-symbols", manifest.GetProperty("capability_gaps").EnumerateArray().Select(gap => gap.GetString())); + Assert.Contains("Directory.Packages.props", manifest.GetProperty("exact_filenames").EnumerateArray().Select(value => value.GetString())); + Assert.True(lockfile.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(lockfile.GetProperty("reference_extraction").GetBoolean()); + Assert.True(lockfile.GetProperty("graph_queries").GetBoolean()); + Assert.DoesNotContain("missing-symbols", lockfile.GetProperty("capability_gaps").EnumerateArray().Select(gap => gap.GetString())); + Assert.Contains("packages.lock.json", lockfile.GetProperty("exact_filenames").EnumerateArray().Select(value => value.GetString())); - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages"); - var csharp = languages.EnumerateArray().First(lang => lang.GetProperty("lang").GetString() == "csharp"); - var aliases = csharp.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString()).ToList(); + // Scientific/native and Prolog reference capabilities (#4738, #4746). + foreach (var language in new[] { "ada", "ambiguous_m", "cython", "d", "julia", "matlab", "nim" }) + { + Assert.True(languages[language].GetProperty("symbol_extraction").GetBoolean(), $"{language} must advertise symbol extraction"); + Assert.True(languages[language].GetProperty("reference_extraction").GetBoolean(), $"{language} must advertise reference extraction"); + Assert.True(languages[language].GetProperty("graph_queries").GetBoolean(), $"{language} must advertise graph queries"); + } - Assert.Contains("cshtml", aliases); - Assert.Contains("razor", aliases); - } + foreach (var language in new[] { "prolog", "ambiguous_pl" }) + { + Assert.True(languages[language].GetProperty("symbol_extraction").GetBoolean(), $"{language} must advertise symbol extraction"); + Assert.True(languages[language].GetProperty("reference_extraction").GetBoolean(), $"{language} must advertise reference extraction"); + Assert.True(languages[language].GetProperty("graph_queries").GetBoolean(), $"{language} must advertise graph queries"); + } + Assert.Contains(".m", languages["ambiguous_m"].GetProperty("extensions").EnumerateArray().Select(value => value.GetString())); + Assert.Contains(".pl", languages["ambiguous_pl"].GetProperty("extensions").EnumerateArray().Select(value => value.GetString())); - [Fact] - public void RunLanguages_Json_ExtractorBucketsAdvertiseAccurateGraphSupport_Issue4743() - { - // Every extractor bucket must advertise the graph support implemented by its - // dedicated reference extractor. - // 各 extractor bucket は専用 reference extractor の実装どおりに graph 対応を広告する。 - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); + // Pin the #215 surface: HTML must be discoverable before indexing with symbol and + // reference extraction plus all four supported extensions. + // #215 の表面契約を pin: HTML はインデックス前でも symbol / reference extraction と + // 4つの対応拡張子を含む言語として検出できる必要がある。 + var html = languages["html"]; + Assert.True(html.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(html.GetProperty("reference_extraction").GetBoolean()); + var htmlExtensions = html.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString()).ToList(); + Assert.Contains(".html", htmlExtensions); + Assert.Contains(".htm", htmlExtensions); + Assert.Contains(".xhtml", htmlExtensions); + Assert.Contains(".shtml", htmlExtensions); - using var document = ParseJsonOutput(stdout); - var languages = document.RootElement.GetProperty("languages").EnumerateArray() - .ToDictionary(entry => entry.GetProperty("lang").GetString()!, entry => entry); + var assembly = languages["assembly"]; + Assert.True(assembly.GetProperty("symbol_extraction").GetBoolean()); + Assert.True(assembly.GetProperty("reference_extraction").GetBoolean()); + Assert.True(assembly.GetProperty("graph_queries").GetBoolean()); + var assemblyExtensions = assembly.GetProperty("extensions").EnumerateArray().Select(ext => ext.GetString()).ToList(); + Assert.Contains(".s", assemblyExtensions); + Assert.Contains(".S", assemblyExtensions); + Assert.Contains(".asm", assemblyExtensions); + Assert.Contains(".nasm", assemblyExtensions); + var assemblyAliases = assembly.GetProperty("aliases").EnumerateArray().Select(alias => alias.GetString()).ToList(); + Assert.Contains("asm", assemblyAliases); + Assert.Contains("assembler", assemblyAliases); + Assert.Contains("gas", assemblyAliases); + Assert.Contains("gnuasm", assemblyAliases); + Assert.Contains("gnu assembler", assemblyAliases); + + var csharpAliases = languages["csharp"].GetProperty("aliases").EnumerateArray() + .Select(alias => alias.GetString()).ToList(); + Assert.Contains("cshtml", csharpAliases); + Assert.Contains("razor", csharpAliases); foreach (var functionalGraphLanguage in new[] { "clojure", "erlang", "ocaml", "raku" }) { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs index fa96ffc29..1053203fa 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs @@ -9,127 +9,55 @@ public partial class QueryCommandRunnerTests { private const int UnusedPageByteBudgetIssue4905 = 5_500; - [Theory] - [InlineData(false, false)] - [InlineData(true, false)] - [InlineData(false, true)] - public void RunUnused_MaxJsonBytesPagesWholeUnicodeRowsAndResumes_Issue4905( - bool compact, - bool byBucket) + [Fact] + public void RunUnused_MaxJsonBytesSharesUnicodeFixtureAcrossPagingAndCursorContracts_Issues4904And4905() { var (projectRoot, dbPath) = CreateUnusedByteBudgetFixtureDbIssue4905(); try { - var legacyArgs = new List + var legacyArgs = new[] { - "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", "--limit", "100", + "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", + "--by-bucket", "--limit", "100", }; var (legacyExitCode, legacyStdout, legacyStderr) = CaptureConsole(() => - ProgramRunner.Run([.. legacyArgs], _jsonOptions, "1.0.0-test")); + ProgramRunner.Run(legacyArgs, _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.Success, legacyExitCode); Assert.Equal(string.Empty, legacyStderr); - using var legacyDocument = JsonDocument.Parse(legacyStdout); - Assert.False(legacyDocument.RootElement.TryGetProperty("metadata", out _)); - var expectedRows = legacyDocument.RootElement - .GetProperty("symbols") - .EnumerateArray() - .Select(ReadUnusedIdentityIssue4905) - .ToArray(); - Assert.Contains( - legacyDocument.RootElement.GetProperty("symbols").EnumerateArray(), - row => row.GetProperty("name").GetString() == "未使用方法00" - && row.GetProperty("signature").GetString()!.Contains("引数23", StringComparison.Ordinal)); - - var baseArgs = new List + (string Path, int Line, string Name)[] expectedRows; + using (var legacyDocument = JsonDocument.Parse(legacyStdout)) { - "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", - "--limit", "100", "--max-json-bytes", UnusedPageByteBudgetIssue4905.ToString(), - }; - if (compact) - baseArgs.Add("--compact"); - if (byBucket) - baseArgs.Add("--by-bucket"); + Assert.False(legacyDocument.RootElement.TryGetProperty("metadata", out _)); + expectedRows = legacyDocument.RootElement + .GetProperty("symbols") + .EnumerateArray() + .Select(ReadUnusedIdentityIssue4905) + .ToArray(); + Assert.Contains( + legacyDocument.RootElement.GetProperty("symbols").EnumerateArray(), + row => row.GetProperty("name").GetString() == "未使用方法00" + && row.GetProperty("signature").GetString()!.Contains("引数23", StringComparison.Ordinal)); + } - var actualRows = new List<(string Path, int Line, string Name)>(); - string? cursor = null; - var pageCount = 0; - do + Assert.True(expectedRows.Length > 1); + AssertLegacyCanonicalPagingIssue4904(dbPath, legacyStdout, expectedRows); + foreach (var projection in new[] + { + (Compact: false, ByBucket: false), + (Compact: true, ByBucket: false), + (Compact: false, ByBucket: true), + }) { - var args = cursor is null - ? baseArgs.ToArray() - : baseArgs.Concat(["--cursor", cursor]).ToArray(); - var (exitCode, stdout, stderr) = CaptureConsole(() => - ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.True( - Encoding.UTF8.GetByteCount(stdout) <= UnusedPageByteBudgetIssue4905, - $"stdout exceeded {UnusedPageByteBudgetIssue4905} UTF-8 bytes."); - using var document = JsonDocument.Parse(stdout); - var root = document.RootElement; - var metadata = root.GetProperty("metadata"); - var results = root.GetProperty("results").EnumerateArray().ToArray(); - - pageCount++; - Assert.True(pageCount <= expectedRows.Length, "unused byte-budget cursor did not make forward progress."); - Assert.NotEmpty(results); - Assert.Equal("unused", metadata.GetProperty("command").GetString()); - Assert.Equal("symbols", metadata.GetProperty("primary_collection").GetString()); - Assert.Equal(expectedRows.Length, metadata.GetProperty("total_count").GetInt32()); - Assert.True(metadata.GetProperty("total_count_authoritative").GetBoolean()); - Assert.Equal(results.Length, metadata.GetProperty("returned_count").GetInt32()); - Assert.Equal( - results.Length, - metadata.GetProperty("response_context").GetProperty("count").GetInt32()); - Assert.Equal( - results.Length, - metadata - .GetProperty("response_context") - .GetProperty("returned_bucket_counts") - .EnumerateObject() - .Sum(property => property.Value.GetInt32())); - - if (compact) - { - Assert.Equal("compact", metadata.GetProperty("format").GetString()); - Assert.All(results, row => - { - Assert.False(row.TryGetProperty("signature", out _)); - Assert.False(row.TryGetProperty("unused_reason", out _)); - Assert.True(row.TryGetProperty("unused_bucket", out _)); - }); - } - else - { - Assert.All(results, row => Assert.True(row.TryGetProperty("signature", out _))); - } - - if (byBucket) - { - var flattened = root.GetProperty("by_bucket") - .EnumerateObject() - .SelectMany(property => property.Value.EnumerateArray()) - .Select(ReadUnusedIdentityIssue4905) - .ToArray(); - Assert.Equal(results.Select(ReadUnusedIdentityIssue4905).Order(), flattened.Order()); - } - else - { - Assert.False(root.TryGetProperty("by_bucket", out _)); - } - - actualRows.AddRange(results.Select(ReadUnusedIdentityIssue4905)); - cursor = metadata.GetProperty("next_cursor").GetString(); - if (cursor is not null) - Assert.StartsWith("response:v2:", cursor, StringComparison.Ordinal); + AssertEnvelopePagingIssue4905( + dbPath, + expectedRows, + projection.Compact, + projection.ByBucket); } - while (cursor is not null); - Assert.True(pageCount > 1); - Assert.Equal(expectedRows, actualRows); - Assert.Equal(actualRows.Count, actualRows.Distinct().Count()); + AssertByteBudgetBoundariesAndHelpIssues4904And4905(dbPath); + AssertCursorBindingAndGenerationIssue4905(dbPath); } finally { @@ -137,131 +65,317 @@ public void RunUnused_MaxJsonBytesPagesWholeUnicodeRowsAndResumes_Issue4905( } } - [Fact] - public void RunUnused_MaxJsonBytesHandlesMinimumEmptyExactBoundaryAndHelp_Issue4905() + private void AssertLegacyCanonicalPagingIssue4904( + string dbPath, + string unboundedStdout, + IReadOnlyList<(string Path, int Line, string Name)> expectedRows) { - var (projectRoot, dbPath) = CreateUnusedByteBudgetFixtureDbIssue4905(); - try + var byteBudget = Encoding.UTF8.GetByteCount(unboundedStdout) - 1; + var actualRows = new List<(string Path, int Line, string Name)>(); + string? cursor = null; + var pageCount = 0; + var sawTruncatedPage = false; + do { - var (smallExitCode, smallStdout, smallStderr) = CaptureConsole(() => - ProgramRunner.Run( - [ - "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", - "--max-json-bytes", "64", - ], - _jsonOptions, - "1.0.0-test")); - - Assert.Equal(CommandExitCodes.UsageError, smallExitCode); - Assert.Equal(string.Empty, smallStderr); - using var smallDocument = JsonDocument.Parse(smallStdout); - var smallError = smallDocument.RootElement; - Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, smallError.GetProperty("error_code").GetString()); - Assert.Equal("response_budget", smallError.GetProperty("category").GetString()); - Assert.Equal("unused", smallError.GetProperty("command").GetString()); - Assert.Contains( - "bounded response metadata and one projected row", - smallError.GetProperty("message").GetString(), - StringComparison.Ordinal); - Assert.Equal(64, smallError.GetProperty("requested_bytes").GetInt64()); - - var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => - ProgramRunner.Run( - [ - "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", - "--path", "does-not-exist/**", - "--max-json-bytes", UnusedPageByteBudgetIssue4905.ToString(), - ], - _jsonOptions, - "1.0.0-test")); - - Assert.Equal(CommandExitCodes.Success, emptyExitCode); - Assert.Equal(string.Empty, emptyStderr); - Assert.True(Encoding.UTF8.GetByteCount(emptyStdout) <= UnusedPageByteBudgetIssue4905); - using (var emptyDocument = JsonDocument.Parse(emptyStdout)) + var args = new List { - Assert.Empty(emptyDocument.RootElement.GetProperty("results").EnumerateArray()); - Assert.Equal(0, emptyDocument.RootElement.GetProperty("metadata").GetProperty("total_count").GetInt32()); - Assert.Null(emptyDocument.RootElement.GetProperty("metadata").GetProperty("next_cursor").GetString()); + "--db", dbPath, + "--json", + "--all", + "--lang", "csharp", + "--by-bucket", + "--limit", "100", + "--max-json-bytes", byteBudget.ToString(), + }; + if (cursor is not null) + { + args.Add("--cursor"); + args.Add(cursor); } - const string unicodeJson = """{"results":[{"name":"未使用猫"}]}"""; - var exactBudget = Encoding.UTF8.GetByteCount(unicodeJson) - + Encoding.UTF8.GetByteCount(Environment.NewLine); - Assert.True(JsonEnvelopeWrapper.JsonFitsResponseBudget(unicodeJson, exactBudget)); - Assert.False(JsonEnvelopeWrapper.JsonFitsResponseBudget(unicodeJson, exactBudget - 1)); - - var flag = Assert.Single( - CliFlagSchema.GetCompletionFlagsForCommand("unused"), - candidate => candidate.Name == "--max-json-bytes"); - Assert.Contains("Bound emitted JSON bytes", flag.Description, StringComparison.Ordinal); - var (printed, helpStdout, helpStderr) = CaptureConsole(() => - ConsoleUi.PrintCommandUsage("unused") ? 1 : 0); - Assert.Equal(1, printed); - Assert.Equal(string.Empty, helpStderr); - Assert.Contains("--max-json-bytes ", helpStdout, StringComparison.Ordinal); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + args.ToArray(), + _jsonOptions)); + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var pageRows = json.GetProperty("symbols") + .EnumerateArray() + .Select(ReadUnusedIdentityIssue4905) + .ToArray(); + + pageCount++; + Assert.True(pageCount <= expectedRows.Count, "legacy unused byte-budget cursor did not make forward progress."); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.NotEmpty(pageRows); + Assert.True(Encoding.UTF8.GetByteCount(stdout) <= byteBudget); + Assert.Equal(byteBudget, json.GetProperty("output_byte_limit").GetInt32()); + Assert.Equal(pageRows.Length, json.GetProperty("count").GetInt32()); + sawTruncatedPage |= json.GetProperty("truncated").GetBoolean(); + actualRows.AddRange(pageRows); + cursor = json.TryGetProperty("next_cursor", out var cursorElement) + ? cursorElement.GetString() + : null; } + while (cursor is not null); + + Assert.True(sawTruncatedPage); + Assert.Equal(expectedRows, actualRows); + Assert.Equal(actualRows.Count, actualRows.Distinct().Count()); } - [Fact] - public void RunUnused_MaxJsonBytesBindsCursorToFiltersAndIndexGeneration_Issue4905() + private void AssertEnvelopePagingIssue4905( + string dbPath, + IReadOnlyList<(string Path, int Line, string Name)> expectedRows, + bool compact, + bool byBucket) { - var (projectRoot, dbPath) = CreateUnusedByteBudgetFixtureDbIssue4905(); - try + var baseArgs = new List { - var baseArgs = new[] + "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", + "--limit", "100", "--max-json-bytes", UnusedPageByteBudgetIssue4905.ToString(), + }; + if (compact) + baseArgs.Add("--compact"); + if (byBucket) + baseArgs.Add("--by-bucket"); + + var actualRows = new List<(string Path, int Line, string Name)>(); + string? cursor = null; + var pageCount = 0; + do + { + var args = cursor is null + ? baseArgs.ToArray() + : baseArgs.Concat(["--cursor", cursor]).ToArray(); + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True( + Encoding.UTF8.GetByteCount(stdout) <= UnusedPageByteBudgetIssue4905, + $"stdout exceeded {UnusedPageByteBudgetIssue4905} UTF-8 bytes."); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + var metadata = root.GetProperty("metadata"); + var results = root.GetProperty("results").EnumerateArray().ToArray(); + + pageCount++; + Assert.True(pageCount <= expectedRows.Count, "unused byte-budget cursor did not make forward progress."); + Assert.NotEmpty(results); + Assert.Equal("unused", metadata.GetProperty("command").GetString()); + Assert.Equal("symbols", metadata.GetProperty("primary_collection").GetString()); + Assert.Equal(expectedRows.Count, metadata.GetProperty("total_count").GetInt32()); + Assert.True(metadata.GetProperty("total_count_authoritative").GetBoolean()); + Assert.Equal(results.Length, metadata.GetProperty("returned_count").GetInt32()); + Assert.Equal( + results.Length, + metadata.GetProperty("response_context").GetProperty("count").GetInt32()); + Assert.Equal( + results.Length, + metadata + .GetProperty("response_context") + .GetProperty("returned_bucket_counts") + .EnumerateObject() + .Sum(property => property.Value.GetInt32())); + + if (compact) { - "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", - "--limit", "1", "--max-json-bytes", "20000", - }; - var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => - ProgramRunner.Run(baseArgs, _jsonOptions, "1.0.0-test")); - - Assert.Equal(CommandExitCodes.Success, firstExitCode); - Assert.Equal(string.Empty, firstStderr); - using var firstDocument = JsonDocument.Parse(firstStdout); - var cursor = firstDocument.RootElement - .GetProperty("metadata") - .GetProperty("next_cursor") - .GetString(); - Assert.NotNull(cursor); - - var (mismatchExitCode, mismatchStdout, mismatchStderr) = CaptureConsole(() => - ProgramRunner.Run( - baseArgs.Concat(["--bucket", "likely_unused_private", "--cursor", cursor!]).ToArray(), - _jsonOptions, - "1.0.0-test")); - - Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); - Assert.Equal(string.Empty, mismatchStdout); - Assert.Contains("cursor_mismatch", mismatchStderr, StringComparison.Ordinal); - - TestProjectHelper.InsertIndexedFile( - dbPath, - "src/GenerationChange.cs", - "csharp", - "internal sealed class GenerationChange { private void NewlyUnused() { } }"); - using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) - new DbWriter(db.Connection).MarkGraphReady(); - - var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => - ProgramRunner.Run( - baseArgs.Concat(["--cursor", cursor!]).ToArray(), - _jsonOptions, - "1.0.0-test")); - - Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("cursor_stale", staleStderr, StringComparison.Ordinal); + Assert.Equal("compact", metadata.GetProperty("format").GetString()); + Assert.All(results, row => + { + Assert.False(row.TryGetProperty("signature", out _)); + Assert.False(row.TryGetProperty("unused_reason", out _)); + Assert.True(row.TryGetProperty("unused_bucket", out _)); + }); + } + else + { + Assert.All(results, row => Assert.True(row.TryGetProperty("signature", out _))); + } + + if (byBucket) + { + var flattened = root.GetProperty("by_bucket") + .EnumerateObject() + .SelectMany(property => property.Value.EnumerateArray()) + .Select(ReadUnusedIdentityIssue4905) + .ToArray(); + Assert.Equal(results.Select(ReadUnusedIdentityIssue4905).Order(), flattened.Order()); + } + else + { + Assert.False(root.TryGetProperty("by_bucket", out _)); + } + + actualRows.AddRange(results.Select(ReadUnusedIdentityIssue4905)); + cursor = metadata.GetProperty("next_cursor").GetString(); + if (cursor is not null) + Assert.StartsWith("response:v2:", cursor, StringComparison.Ordinal); } - finally + while (cursor is not null); + + Assert.True(pageCount > 1); + Assert.Equal(expectedRows, actualRows); + Assert.Equal(actualRows.Count, actualRows.Distinct().Count()); + } + + private void AssertByteBudgetBoundariesAndHelpIssues4904And4905(string dbPath) + { + var (unboundedCompactExitCode, unboundedCompactStdout, unboundedCompactStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + ["--db", dbPath, "--compact", "--all", "--lang", "csharp", "--by-bucket"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, unboundedCompactExitCode); + Assert.Equal(string.Empty, unboundedCompactStderr); + var compactByteBudget = Encoding.UTF8.GetByteCount(unboundedCompactStdout) + 128; + var (compactExitCode, compactStdout, compactStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + [ + "--db", dbPath, "--compact", "--all", "--lang", "csharp", "--by-bucket", + "--max-json-bytes", compactByteBudget.ToString(), + ], + _jsonOptions)); + using var compactDocument = ParseJsonOutput(compactStdout); + Assert.Equal(CommandExitCodes.Success, compactExitCode); + Assert.Equal(string.Empty, compactStderr); + Assert.True(Encoding.UTF8.GetByteCount(compactStdout) <= compactByteBudget); + Assert.False(compactDocument.RootElement.TryGetProperty("symbols", out _)); + + var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--count", "--all", "--max-json-bytes", "65536"], + _jsonOptions)); + using var countDocument = ParseJsonOutput(countStdout); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + Assert.Equal(65536, countDocument.RootElement.GetProperty("output_byte_limit").GetInt32()); + Assert.False(countDocument.RootElement.GetProperty("truncated").GetBoolean()); + Assert.Equal(0, countDocument.RootElement.GetProperty("omitted_count").GetInt32()); + + var (legacyEmptyExitCode, legacyEmptyStdout, legacyEmptyStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--lang", "rust", "--max-json-bytes", "65536"], + _jsonOptions)); + using var legacyEmptyDocument = ParseJsonOutput(legacyEmptyStdout); + Assert.Equal(CommandExitCodes.Success, legacyEmptyExitCode); + Assert.Equal(string.Empty, legacyEmptyStderr); + Assert.Equal(0, legacyEmptyDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Empty(legacyEmptyDocument.RootElement.GetProperty("symbols").EnumerateArray()); + + var (legacySmallExitCode, legacySmallStdout, legacySmallStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--all", "--max-json-bytes", "1"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, legacySmallExitCode); + Assert.Equal(string.Empty, legacySmallStdout); + Assert.Contains("one canonical symbol row", legacySmallStderr); + + var (smallExitCode, smallStdout, smallStderr) = CaptureConsole(() => + ProgramRunner.Run( + [ + "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", + "--max-json-bytes", "64", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, smallExitCode); + Assert.Equal(string.Empty, smallStderr); + using var smallDocument = JsonDocument.Parse(smallStdout); + var smallError = smallDocument.RootElement; + Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, smallError.GetProperty("error_code").GetString()); + Assert.Equal("response_budget", smallError.GetProperty("category").GetString()); + Assert.Equal("unused", smallError.GetProperty("command").GetString()); + Assert.Contains( + "bounded response metadata and one projected row", + smallError.GetProperty("message").GetString(), + StringComparison.Ordinal); + Assert.Equal(64, smallError.GetProperty("requested_bytes").GetInt64()); + + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => + ProgramRunner.Run( + [ + "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", + "--path", "does-not-exist/**", + "--max-json-bytes", UnusedPageByteBudgetIssue4905.ToString(), + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + Assert.True(Encoding.UTF8.GetByteCount(emptyStdout) <= UnusedPageByteBudgetIssue4905); + using (var emptyDocument = JsonDocument.Parse(emptyStdout)) { - TestProjectHelper.DeleteDirectory(projectRoot); + Assert.Empty(emptyDocument.RootElement.GetProperty("results").EnumerateArray()); + Assert.Equal(0, emptyDocument.RootElement.GetProperty("metadata").GetProperty("total_count").GetInt32()); + Assert.Null(emptyDocument.RootElement.GetProperty("metadata").GetProperty("next_cursor").GetString()); } + + const string unicodeJson = """{"results":[{"name":"未使用猫"}]}"""; + var exactBudget = Encoding.UTF8.GetByteCount(unicodeJson) + + Encoding.UTF8.GetByteCount(Environment.NewLine); + Assert.True(JsonEnvelopeWrapper.JsonFitsResponseBudget(unicodeJson, exactBudget)); + Assert.False(JsonEnvelopeWrapper.JsonFitsResponseBudget(unicodeJson, exactBudget - 1)); + + var flag = Assert.Single( + CliFlagSchema.GetCompletionFlagsForCommand("unused"), + candidate => candidate.Name == "--max-json-bytes"); + Assert.Contains("Bound emitted JSON bytes", flag.Description, StringComparison.Ordinal); + var (printed, helpStdout, helpStderr) = CaptureConsole(() => + ConsoleUi.PrintCommandUsage("unused") ? 1 : 0); + Assert.Equal(1, printed); + Assert.Equal(string.Empty, helpStderr); + Assert.Contains("--max-json-bytes ", helpStdout, StringComparison.Ordinal); + } + + private void AssertCursorBindingAndGenerationIssue4905(string dbPath) + { + var baseArgs = new[] + { + "unused", "--db", dbPath, "--json", "--all", "--lang", "csharp", + "--limit", "1", "--max-json-bytes", "20000", + }; + var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => + ProgramRunner.Run(baseArgs, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + using var firstDocument = JsonDocument.Parse(firstStdout); + var cursor = firstDocument.RootElement + .GetProperty("metadata") + .GetProperty("next_cursor") + .GetString(); + Assert.NotNull(cursor); + + var (mismatchExitCode, mismatchStdout, mismatchStderr) = CaptureConsole(() => + ProgramRunner.Run( + baseArgs.Concat(["--bucket", "likely_unused_private", "--cursor", cursor!]).ToArray(), + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); + Assert.Equal(string.Empty, mismatchStdout); + Assert.Contains("cursor_mismatch", mismatchStderr, StringComparison.Ordinal); + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/GenerationChange.cs", + "csharp", + "internal sealed class GenerationChange { private void NewlyUnused() { } }"); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + new DbWriter(db.Connection).MarkGraphReady(); + + var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => + ProgramRunner.Run( + baseArgs.Concat(["--cursor", cursor!]).ToArray(), + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, staleExitCode); + Assert.Equal(string.Empty, staleStdout); + Assert.Contains("cursor_stale", staleStderr, StringComparison.Ordinal); } private static (string ProjectRoot, string DbPath) CreateUnusedByteBudgetFixtureDbIssue4905() diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs index 72056ca3b..9b4082bb2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs @@ -10,13 +10,21 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { [Fact] - public void RunValidate_LimitAndTopCapReturnedIssues_Issue2992() + public void RunValidate_IndexedIssueViewsShareSupersetFixture_Issues1582_2992_3010_3896_3897_4908() { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_limit"); + const string primaryBomPath = "src/App.cs"; + const string cleanPath = "src/clean.cs"; + const string excludedRoot = "src/excluded"; + const string mixedPath = "src/excluded/mixed.cs"; + + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_views"); var projectRoot = project.Root; var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - WriteUtf8BomFile(projectRoot, "src/bom.cs", "class Bom {}\n"); - TestProjectHelper.WriteTextFile(projectRoot, "src/mixed.cs", "class Mixed {}\r\nclass Other {}\n"); + WriteUtf8BomFile(projectRoot, primaryBomPath, "class App {}\n"); + TestProjectHelper.WriteTextFile(projectRoot, cleanPath, "class Clean {}\n"); + WriteUtf8BomFile(projectRoot, "src/excluded/Excluded.cs", "class Excluded {}\n"); + TestProjectHelper.WriteTextFile(projectRoot, mixedPath, "class Mixed {}\r\nclass Other {}\n"); + WriteUtf8BomFile(projectRoot, "tests/AppTests.cs", "class AppTests {}\n"); var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( [projectRoot, "--db", dbPath, "--json", "--quiet"], @@ -24,12 +32,12 @@ public void RunValidate_LimitAndTopCapReturnedIssues_Issue2992() Assert.Equal(CommandExitCodes.Success, indexExitCode); Assert.Equal(string.Empty, indexStderr); - var (limitExitCode, limitStdout, limitStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json", "--limit", "1"], - _jsonOptions)); - var (topExitCode, topStdout, topStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json", "--top", "1"], - _jsonOptions)); + (int ExitCode, string Stdout, string Stderr) RunValidate(params string[] args) + => CaptureConsole(() => QueryCommandRunner.RunValidate(["--db", dbPath, .. args], _jsonOptions)); + + // Both pagination aliases cap returned rows without changing the command contract (#2992). + var (limitExitCode, limitStdout, limitStderr) = RunValidate("--json", "--limit", "1"); + var (topExitCode, topStdout, topStderr) = RunValidate("--json", "--top", "1"); using var limitDocument = ParseJsonOutput(limitStdout); using var topDocument = ParseJsonOutput(topStdout); @@ -42,6 +50,79 @@ public void RunValidate_LimitAndTopCapReturnedIssues_Issue2992() Assert.Equal(1, limitDocument.RootElement.GetProperty("issues").GetArrayLength()); Assert.Equal(1, topDocument.RootElement.GetProperty("count").GetInt32()); Assert.Equal(1, topDocument.RootElement.GetProperty("issues").GetArrayLength()); + + // The array projection returns the first deterministic issue with its persisted metadata (#3010). + var (arrayExitCode, arrayStdout, arrayStderr) = RunValidate("--json=array", "--limit", "1"); + using var arrayDocument = ParseJsonOutput(arrayStdout); + var arrayRoot = arrayDocument.RootElement; + Assert.Equal(CommandExitCodes.Success, arrayExitCode); + Assert.Equal(string.Empty, arrayStderr); + Assert.Equal(JsonValueKind.Array, arrayRoot.ValueKind); + Assert.Equal(1, arrayRoot.GetArrayLength()); + Assert.Equal("bom", arrayRoot[0].GetProperty("kind").GetString()); + Assert.Equal(FileIssue.OriginByteOrderMark, arrayRoot[0].GetProperty("origin").GetString()); + Assert.Equal(FileIssue.SeverityWarning, arrayRoot[0].GetProperty("severity").GetString()); + + // A path-scoped clean file exercises the empty-array branch without rebuilding the index (#3010). + var (emptyExitCode, emptyStdout, emptyStderr) = RunValidate("--json=array", "--path", cleanPath); + using var emptyDocument = ParseJsonOutput(emptyStdout); + var emptyRoot = emptyDocument.RootElement; + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, emptyStderr); + Assert.Equal(JsonValueKind.Array, emptyRoot.ValueKind); + Assert.Empty(emptyRoot.EnumerateArray()); + + // A trailing --json must retain the count envelope selected by --format count (#3896, #4908). + var (countExitCode, countStdout, countStderr) = RunValidate( + "--path", primaryBomPath, "--format", "count", "--json"); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + using var countDocument = ParseJsonOutput(countStdout); + var countRoot = countDocument.RootElement; + Assert.Equal(1, countRoot.GetProperty("count").GetInt32()); + Assert.Equal(1, countRoot.GetProperty("total_estimated").GetInt32()); + Assert.Equal(JsonOutputContract.ApiVersion, countRoot.GetProperty("api_version").GetString()); + Assert.Equal("validation_issues", countRoot.GetProperty("count_kind").GetString()); + Assert.Equal("all_matching_issues_before_limit", countRoot.GetProperty("count_scope").GetString()); + Assert.True(countRoot.GetProperty("authoritative_count").GetBoolean()); + Assert.False(countRoot.TryGetProperty("issues", out _)); + + // Scope one BOM and one mixed-line-ending issue, then prove --kind narrows to the BOM. + var (kindExitCode, kindStdout, kindStderr) = RunValidate( + "--json", "--path", primaryBomPath, "--path", mixedPath, "--kind", "bom"); + using var kindDocument = ParseJsonOutput(kindStdout); + var kindRoot = kindDocument.RootElement; + Assert.Equal(CommandExitCodes.Success, kindExitCode); + Assert.Equal(string.Empty, kindStderr); + Assert.Equal(1, kindRoot.GetProperty("count").GetInt32()); + Assert.Equal("bom", kindRoot.GetProperty("issues")[0].GetProperty("kind").GetString()); + Assert.Equal(FileIssue.OriginByteOrderMark, kindRoot.GetProperty("issues")[0].GetProperty("origin").GetString()); + Assert.Equal(FileIssue.SeverityWarning, kindRoot.GetProperty("issues")[0].GetProperty("severity").GetString()); + + // `validate --kind replacement_chra` previously filtered the file_issues table by an + // unknown kind, returned zero rows, and printed the same "No encoding issues found." + // message a genuinely-clean repo would print — silently masking the typo. Round-2 adds + // a known-kind allowlist + did-you-mean hint (#1582). + // 従来 `validate --kind replacement_chra` は file_issues を 0 行に絞り込み、本当に + // クリーンな状態と同じ "No encoding issues found." を出して typo を握り潰していた。 + // round-2 で許可された kind 一覧と did-you-mean を追加した (#1582)。 + var (typoExitCode, _, typoStderr) = RunValidate("--kind", "replacement_chra"); + Assert.Equal(CommandExitCodes.Success, typoExitCode); + Assert.Contains("No encoding issues found.", typoStderr); + Assert.Contains("'replacement_chra' is not a known validate kind", typoStderr); + Assert.Contains("Did you mean: --kind replacement_char?", typoStderr); + + // Test and explicit path exclusions leave only the primary BOM issue (#3897). + var (excludeExitCode, excludeStdout, excludeStderr) = RunValidate( + "--json", "--exclude-tests", "--exclude-path", excludedRoot); + using var excludeDocument = ParseJsonOutput(excludeStdout); + var excludeRoot = excludeDocument.RootElement; + var excludeIssues = excludeRoot.GetProperty("issues"); + Assert.Equal(CommandExitCodes.Success, excludeExitCode); + Assert.Equal(string.Empty, excludeStderr); + Assert.Equal(1, excludeRoot.GetProperty("count").GetInt32()); + Assert.Equal(primaryBomPath, excludeIssues[0].GetProperty("path").GetString()); + Assert.Equal("bom", excludeIssues[0].GetProperty("kind").GetString()); } [Theory] @@ -61,95 +142,6 @@ public void RunValidate_InvalidLimitOrTopReturnsUsageError_Issue2992(string flag Assert.DoesNotContain("database not found", stderr); } - [Fact] - public void RunValidate_JsonArrayEmitsIssueArray_Issue3010() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_json_array"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - WriteUtf8BomFile(projectRoot, "src/bom.cs", "class Bom {}\n"); - TestProjectHelper.WriteTextFile(projectRoot, "src/clean.cs", "class Clean {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json=array", "--limit", "1"], - _jsonOptions)); - - using var document = ParseJsonOutput(stdout); - var root = document.RootElement; - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.Equal(JsonValueKind.Array, root.ValueKind); - Assert.Equal(1, root.GetArrayLength()); - Assert.Equal("bom", root[0].GetProperty("kind").GetString()); - Assert.Equal(FileIssue.OriginByteOrderMark, root[0].GetProperty("origin").GetString()); - Assert.Equal(FileIssue.SeverityWarning, root[0].GetProperty("severity").GetString()); - } - - [Fact] - public void RunValidate_JsonArrayEmptyEmitsEmptyArray_Issue3010() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_json_array_empty"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.WriteTextFile(projectRoot, "src/clean.cs", "class Clean {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json=array"], - _jsonOptions)); - - using var document = ParseJsonOutput(stdout); - var root = document.RootElement; - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.Equal(JsonValueKind.Array, root.ValueKind); - Assert.Empty(root.EnumerateArray()); - } - - [Fact] - public void RunValidate_FormatCountThenJsonKeepsCompatibleEnvelope_Issues3896And4908() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_count_json_3896"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - WriteUtf8BomFile(projectRoot, "src/bom.cs", "class Bom {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--format", "count", "--json"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - using var document = ParseJsonOutput(stdout); - var root = document.RootElement; - Assert.Equal(1, root.GetProperty("count").GetInt32()); - Assert.Equal(1, root.GetProperty("total_estimated").GetInt32()); - Assert.Equal(JsonOutputContract.ApiVersion, root.GetProperty("api_version").GetString()); - Assert.Equal("validation_issues", root.GetProperty("count_kind").GetString()); - Assert.Equal("all_matching_issues_before_limit", root.GetProperty("count_scope").GetString()); - Assert.True(root.GetProperty("authoritative_count").GetBoolean()); - Assert.False(root.TryGetProperty("issues", out _)); - } - [Fact] public void RunValidate_InvalidSeverityJsonReturnsStructuredError_Issue3896() { @@ -164,98 +156,6 @@ public void RunValidate_InvalidSeverityJsonReturnsStructuredError_Issue3896() Assert.Equal("unsupported validate severity 'invalid'.", document.RootElement.GetProperty("message").GetString()); } - [Fact] - public void RunValidate_KindFilterNarrowsIssues() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_kind_filter"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - WriteUtf8BomFile(projectRoot, "src/bom.cs", "class Bom {}\n"); - TestProjectHelper.WriteTextFile(projectRoot, "src/mixed.cs", "class Mixed {}\r\nclass Other {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json", "--kind", "bom"], - _jsonOptions)); - - using var document = ParseJsonOutput(stdout); - var json = document.RootElement; - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.Equal(1, json.GetProperty("count").GetInt32()); - Assert.Equal("bom", json.GetProperty("issues")[0].GetProperty("kind").GetString()); - Assert.Equal(FileIssue.OriginByteOrderMark, json.GetProperty("issues")[0].GetProperty("origin").GetString()); - Assert.Equal(FileIssue.SeverityWarning, json.GetProperty("issues")[0].GetProperty("severity").GetString()); - } - - // `validate --kind replacement_chra` previously filtered the file_issues table by an - // unknown kind, returned zero rows, and printed the same "No encoding issues found." - // message a genuinely-clean repo would print — silently masking the typo. Round-2 adds - // a known-kind allowlist + did-you-mean hint (#1582). - // 従来 `validate --kind replacement_chra` は file_issues を 0 行に絞り込み、本当に - // クリーンな状態と同じ "No encoding issues found." を出して typo を握り潰していた。 - // round-2 で許可された kind 一覧と did-you-mean を追加した (#1582)。 - [Fact] - public void RunValidate_KindTypo_SuggestsClosestKind_Issue1582() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_kind_typo"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.WriteTextFile(projectRoot, "src/clean.cs", "class Clean {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--kind", "replacement_chra"], - _jsonOptions)); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains("No encoding issues found.", stderr); - Assert.Contains("'replacement_chra' is not a known validate kind", stderr); - Assert.Contains("Did you mean: --kind replacement_char?", stderr); - } - - [Fact] - public void RunValidate_ExcludeFiltersScopeIssues_Issue3897() - { - using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_exclude_filters"); - var projectRoot = project.Root; - var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - WriteUtf8BomFile(projectRoot, "src/App.cs", "class App {}\n"); - WriteUtf8BomFile(projectRoot, "src/generated/Generated.cs", "class Generated {}\n"); - WriteUtf8BomFile(projectRoot, "tests/AppTests.cs", "class AppTests {}\n"); - - var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( - [projectRoot, "--db", dbPath, "--json", "--quiet"], - _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, indexExitCode); - Assert.Equal(string.Empty, indexStderr); - - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( - ["--db", dbPath, "--json", "--exclude-tests", "--exclude-path", "src/generated"], - _jsonOptions)); - - using var document = ParseJsonOutput(stdout); - var root = document.RootElement; - var issues = root.GetProperty("issues"); - - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - Assert.Equal(1, root.GetProperty("count").GetInt32()); - Assert.Equal("src/App.cs", issues[0].GetProperty("path").GetString()); - Assert.Equal("bom", issues[0].GetProperty("kind").GetString()); - } - [Fact] public void ValidateContent_SuppressesSolutionUtf8BomNoise_Issue3897() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index 89446c16f..d90a4fca1 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Diagnostics; using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -7939,40 +7938,4 @@ public void Run(bool condition, object value, IEnumerable sou && reference.ReferenceKind == "type_reference"); } -#if NET8_0 - [Fact] -#else - [Fact(Skip = PracticalBudgetTestTarget.SecondaryTargetSkipReason)] -#endif - public void Extract_CSharpLargeMethodWithManyLocals_CompletesWithinPracticalBudget() - { - const int localCount = 1_000; - var builder = new StringBuilder(); - builder.AppendLine("class Demo"); - builder.AppendLine("{"); - builder.AppendLine(" int Run(int input)"); - builder.AppendLine(" {"); - builder.AppendLine(" var result = input;"); - for (var i = 0; i < localCount; i++) - { - builder.Append(" var value").Append(i).Append(" = result + ").Append(i).AppendLine(";"); - builder.Append(" result += value").Append(i).AppendLine(";"); - } - builder.AppendLine(" return Helper(result);"); - builder.AppendLine(" }"); - builder.AppendLine(" int Helper(int value) => value;"); - builder.AppendLine("}"); - var content = builder.ToString(); - var symbols = SymbolExtractor.Extract(1, "csharp", content); - - var stopwatch = Stopwatch.StartNew(); - var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); - stopwatch.Stop(); - - Assert.Contains(references, reference => reference.SymbolName == "Helper" && reference.ReferenceKind == "call"); - var runawayBudget = TimeSpan.FromSeconds(5); - Assert.True( - stopwatch.Elapsed < runawayBudget, - $"Large C# method reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); - } } diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCobolTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCobolTests.cs index 6ac96b3b4..5b0648e2c 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCobolTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCobolTests.cs @@ -12,8 +12,13 @@ namespace CodeIndex.Tests; public partial class ReferenceExtractorTests { [Fact] - public void Extract_CobolPerform_CapturesParagraphLevelCallReference() + public void Extract_CobolPerformAndCommonStatements_PreserveCallsReferencesAndContainers() { + // Keep PERFORM range expansion and common statement targets in one program. NEXT-PARA + // deliberately follows EXIT-PARA so it remains outside the HELPER-PARA THRU EXIT-PARA + // range and cannot become an accidental range call. + // PERFORM range と common statement target を1 program にまとめる。NEXT-PARA は + // EXIT-PARA の後へ置き、THRU range による余分な call を防ぐ。 const string content = """ IDENTIFICATION DIVISION. PROGRAM-ID. hello-world. @@ -21,6 +26,25 @@ PROCEDURE DIVISION. MAIN-SECTION SECTION. PERFORM HELPER-SECTION PERFORM HELPER-PARA THRU EXIT-PARA + GO TO NEXT-PARA + OPEN INPUT CUSTOMER-FILE + READ CUSTOMER-FILE + WRITE CUSTOMER-RECORD + SEARCH ALL CUSTOMER-TABLE + START ORDER-FILE KEY IS >= ORDER-KEY + SET HAS-ITEM TO TRUE + MOVE SOURCE-VALUE TO DEST-VALUE + ADD AMOUNT TO TOTAL + SUBTRACT TAX FROM NET + MULTIPLY RATE BY RESULT + DIVIDE GRAND-TOTAL INTO AVERAGE + COMPUTE FINAL-TOTAL = TOTAL + TAX + STRING FIRST-NAME DELIMITED BY SIZE INTO BUFFER + UNSTRING BUFFER INTO PART1 + DISPLAY CUSTOMER-NAME + ACCEPT INPUT-NAME + INSPECT BUFFER + CLOSE CUSTOMER-FILE STOP RUN. HELPER-SECTION SECTION. HELPER-PARA. @@ -29,36 +53,47 @@ STOP RUN. DISPLAY "B". EXIT-PARA. CALL "other-program" + NEXT-PARA. + CONTINUE. END PROGRAM hello-world. """; - var symbols = SymbolExtractor.Extract(1, "cobol", content); - var references = ReferenceExtractor.Extract(1, "cobol", content, symbols); + var (symbols, references) = ExtractSymbolsAndReferences("cobol", content); Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == "MAIN-SECTION"); Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == "HELPER-SECTION"); Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == "HELPER-PARA"); Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == "MIDDLE-PARA"); Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == "EXIT-PARA"); - Assert.Contains(references, reference => - reference.SymbolName == "HELPER-SECTION" - && reference.ReferenceKind == "call" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "HELPER-PARA" - && reference.ReferenceKind == "call" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "MIDDLE-PARA" - && reference.ReferenceKind == "call" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "EXIT-PARA" + AssertReferencesContain( + references, + "call", + "MAIN-SECTION", + "HELPER-SECTION", + "HELPER-PARA", + "MIDDLE-PARA", + "EXIT-PARA", + "NEXT-PARA"); + Assert.Single(references, reference => + reference.SymbolName == "NEXT-PARA" && reference.ReferenceKind == "call" && reference.ContainerName == "MAIN-SECTION"); Assert.Contains(references, reference => reference.SymbolName == "OTHER-PROGRAM" && reference.ReferenceKind == "call"); + AssertReferencesContain( + references, + "reference", + "MAIN-SECTION", + "CUSTOMER-FILE", + "CUSTOMER-TABLE", + "ORDER-FILE", + "HAS-ITEM", + "DEST-VALUE", + "FINAL-TOTAL", + "BUFFER", + "INPUT-NAME", + "CUSTOMER-NAME"); Assert.Contains(ReferenceExtractor.GetSupportedLanguages(), lang => lang == "cobol"); } @@ -84,84 +119,6 @@ END PROGRAM hello-world. && reference.ContainerName == "MAIN-SECTION"); } - [Fact] - public void Extract_CobolCommonStatements_CapturesSearchableReferences() - { - const string content = """ - IDENTIFICATION DIVISION. - PROGRAM-ID. hello-world. - PROCEDURE DIVISION. - MAIN-SECTION SECTION. - GO TO NEXT-PARA - OPEN INPUT CUSTOMER-FILE - READ CUSTOMER-FILE - WRITE CUSTOMER-RECORD - SEARCH ALL CUSTOMER-TABLE - START ORDER-FILE KEY IS >= ORDER-KEY - SET HAS-ITEM TO TRUE - MOVE SOURCE-VALUE TO DEST-VALUE - ADD AMOUNT TO TOTAL - SUBTRACT TAX FROM NET - MULTIPLY RATE BY RESULT - DIVIDE GRAND-TOTAL INTO AVERAGE - COMPUTE FINAL-TOTAL = TOTAL + TAX - STRING FIRST-NAME DELIMITED BY SIZE INTO BUFFER - UNSTRING BUFFER INTO PART1 - DISPLAY CUSTOMER-NAME - ACCEPT INPUT-NAME - INSPECT BUFFER - CLOSE CUSTOMER-FILE - STOP RUN. - NEXT-PARA. - CONTINUE. - END PROGRAM hello-world. - """; - - var symbols = SymbolExtractor.Extract(1, "cobol", content); - var references = ReferenceExtractor.Extract(1, "cobol", content, symbols); - - Assert.Contains(references, reference => - reference.SymbolName == "NEXT-PARA" - && reference.ReferenceKind == "call" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "CUSTOMER-FILE" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "CUSTOMER-TABLE" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "ORDER-FILE" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "HAS-ITEM" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "DEST-VALUE" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "FINAL-TOTAL" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "BUFFER" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "INPUT-NAME" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - Assert.Contains(references, reference => - reference.SymbolName == "CUSTOMER-NAME" - && reference.ReferenceKind == "reference" - && reference.ContainerName == "MAIN-SECTION"); - } - [Fact] public void Extract_CobolTargetStatements_ReuseSingleProgramFixture() { @@ -227,6 +184,9 @@ END PROGRAM hello-world. var symbols = SymbolExtractor.Extract(1, "cobol", content); var references = ReferenceExtractor.Extract(1, "cobol", content, symbols); + Assert.Equal(42, statements.Count(item => item.ReferenceKind == "reference")); + Assert.Equal(4, statements.Count(item => item.ReferenceKind == "call")); + foreach (var expected in statements .GroupBy(item => (item.SymbolName, item.ReferenceKind)) .Select(group => (group.Key.SymbolName, group.Key.ReferenceKind, Count: group.Count()))) diff --git a/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs index 53b0a6df7..b4ef63607 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorJvmTests.cs @@ -209,66 +209,68 @@ public Function, String> opener() { } [Fact] - public void Extract_JavaGenericCallableSignatures_DoNotEmitTypeParameterReferences() + public void Extract_JavaGenericParameters_AcrossSupportedPositions_DoNotEmitTypeReferences() { + // Keep every generic position in one extraction, but use form-specific names so a + // positive from one declaration cannot hide a missing edge or leaked parameter in another. + // generic の各位置を1回の抽出で検証しつつ、形式ごとに固有名を使い、別宣言の正例で + // edge 欠落や parameter 漏出が隠れないようにする。 const string content = """ package demo; - interface Comparable {} - class Payload {} - - class Demo { - public > T pick(T input, Comparable fallback) { + interface CallableComparable {} + class CallableDemo { + public > TCallable pick(TCallable input, CallableComparable fallback) { return input; } } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.Contains(references, r => r.SymbolName == "Comparable" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "T" && r.ReferenceKind == "type_reference"); - } - - [Fact] - public void Extract_JavaGenericHeritage_DoNotEmitTypeParameterReferences() - { - const string content = """ - package demo; - - interface Comparable {} - class Base {} - interface Handler {} - class Box> extends Base implements Handler {} - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - Assert.Contains(references, r => r.SymbolName == "Comparable" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Base" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Handler" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "T" && r.ReferenceKind == "type_reference"); - } + interface HeritageComparable {} + class HeritageBase {} + interface HeritageHandler {} + class HeritageBox> extends HeritageBase implements HeritageHandler {} - [Fact] - public void Extract_JavaGenericThrows_DoNotEmitTypeParameterReferences() - { - const string content = """ - package demo; + class ThrowsFailure extends Exception {} + class ThrowsDemo { + public void run() throws EThrows {} + } - class Failure extends Exception {} - class Demo { - public void run() throws E {} + class BoundRoot {} + interface BoundMarker {} + class BoundWrapper {} + class BoundDemo { + > UMethodBound resolve(UMethodBound value) { + return value; + } } """; - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); + var (_, references) = ExtractSymbolsAndReferences("java", content); - Assert.Contains(references, r => r.SymbolName == "Failure" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "E" && r.ReferenceKind == "type_reference"); + AssertReferencesContain( + references, + "type_reference", + null, + "CallableComparable", + "HeritageComparable", + "HeritageBase", + "HeritageHandler", + "ThrowsFailure", + "BoundRoot", + "BoundMarker", + "BoundWrapper"); + var boundRootCount = references.Count(reference => + reference.SymbolName == "BoundRoot" + && reference.ReferenceKind == "type_reference"); + Assert.True(boundRootCount >= 2, $"Expected at least 2 BoundRoot type references, got {boundRootCount}."); + AssertReferencesDoNotContain( + references, + "type_reference", + "TCallable", + "THeritage", + "EThrows", + "TClassBound", + "UMethodBound"); } [Fact] @@ -858,169 +860,98 @@ class Box } [Fact] - public void Extract_KotlinBacktickTypeReferences_NormalizesNames() + public void Extract_KotlinBacktickReferences_AcrossSupportedForms_NormalizeNames() { - // Kotlin declaration names already strip source-only backticks; type-position - // references need the same canonical name so dependency search joins them. - // Kotlin の宣言名は source-only な backtick を外しているため、型位置参照も同じ - // canonical 名で発行し、依存検索で宣言と結合できるようにする。 + // Backticks are source syntax across type positions, class literals, callable + // references, constructors, and annotations. Unique names keep each form observable. + // backtick は型位置、class literal、callable reference、constructor、annotation に + // またがる source syntax である。形式ごとの固有名により各契約を独立して観測する。 const string content = """ - class `Display Name` - class Holder + class `Backtick Type` + class BacktickHolder - class Demo { - val first: `Display Name` = TODO() - val second: Holder<`Display Name`> = TODO() - val third: `Display Name`? = null - } - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(symbols, s => s.Name == "Display Name" && s.Kind == "class"); - var displayNameTypeReferenceCount = references.Count(r => r.SymbolName == "Display Name" && r.ReferenceKind == "type_reference"); - Assert.True( - displayNameTypeReferenceCount >= 3, - $"Expected at least 3 Display Name type references, got {displayNameTypeReferenceCount}."); - Assert.Contains(references, r => r.SymbolName == "Holder" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "`Display Name`" && r.ReferenceKind == "type_reference"); - } - - [Fact] - public void Extract_KotlinBacktickClassLiterals_NormalizesTypeReferenceNames() - { - // Kotlin class literals can target backticked type names too; keep them aligned with - // the declaration's canonical name instead of treating the backticks as a string. - // Kotlin の class literal でも backtick 付き型名を対象にできるため、backtick を - // 文字列扱いせず、宣言側と同じ canonical 名で参照を発行する。 - const string content = """ - class `Display Name` + class `Backtick Literal` - class Demo { - val token = `Display Name`::class + class `Backtick Owner` { + fun ownerRender() {} } - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(symbols, s => s.Name == "Display Name" && s.Kind == "class"); - Assert.Contains(references, r => r.SymbolName == "Display Name" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "`Display Name`" && r.ReferenceKind == "type_reference"); - } - [Fact] - public void Extract_KotlinBacktickMethodReferenceOwners_CaptureTypeReference() - { - // JVM method references already emit owner type edges for Java/Kotlin; Kotlin backtick - // owners need the same canonical name handling as declarations and type positions. - // JVM method reference の owner 型 edge は Java/Kotlin で発行しているため、 - // Kotlin の backtick owner でも宣言・型位置と同じ canonical 名に揃える。 - const string content = """ - class `Display Name` { - fun render() {} + class CallableOwner { + fun `backtick call`() {} } - class Demo { - val handler = `Display Name`::render - } - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + class `Backtick Constructor` - Assert.Contains(symbols, s => s.Name == "Display Name" && s.Kind == "class"); - Assert.Contains(references, r => r.SymbolName == "Display Name" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "render" && r.ReferenceKind == "call"); - Assert.DoesNotContain(references, r => r.SymbolName == "`Display Name`" && r.ReferenceKind == "type_reference"); - } + annotation class `Backtick Annotation`(val value: String = "") + class AnnotationPayload - [Fact] - public void Extract_KotlinBacktickMethodReferenceNames_NormalizesCallNames() - { - // Backticked Kotlin callable names are source syntax; method-reference call edges should - // use the same canonical name as the callable declaration. - // Kotlin callable 名の backtick は source syntax なので、method reference の call edge も - // 宣言側と同じ canonical 名で発行する。 - const string content = """ - class User { - fun `render name`() {} + class BacktickDemo { + val first: `Backtick Type` = TODO() + val second: BacktickHolder<`Backtick Type`> = TODO() + val third: `Backtick Type`? = null + val literal = `Backtick Literal`::class + val ownerHandler = `Backtick Owner`::ownerRender + val callableHandler = CallableOwner::`backtick call` + val constructed = `Backtick Constructor`() } - class Demo { - val handler = User::`render name` + @`Backtick Annotation` + class AnnotatedBacktickTarget { + @`Backtick Annotation`("x") + fun annotated(input: @`Backtick Annotation` AnnotationPayload) {} } """; - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + var (symbols, references) = ExtractSymbolsAndReferences("kotlin", content); - Assert.Contains(symbols, s => s.Name == "render name" && s.Kind == "function"); - Assert.Contains(references, r => r.SymbolName == "User" && r.ReferenceKind == "type_reference"); - var renderReference = Assert.Single(references.Where(r => - r.SymbolName == "render name" - && r.ReferenceKind == "call")); - var renderLine = content + foreach (var className in new[] + { + "Backtick Type", + "Backtick Literal", + "Backtick Owner", + "Backtick Constructor", + "Backtick Annotation", + }) + { + Assert.Contains(symbols, symbol => symbol.Name == className && symbol.Kind == "class"); + } + Assert.Contains(symbols, symbol => symbol.Name == "backtick call" && symbol.Kind == "function"); + + var backtickTypeCount = references.Count(reference => + reference.SymbolName == "Backtick Type" + && reference.ReferenceKind == "type_reference"); + Assert.True(backtickTypeCount >= 3, $"Expected at least 3 Backtick Type references, got {backtickTypeCount}."); + AssertReferencesContain( + references, + "type_reference", + null, + "BacktickHolder", + "Backtick Literal", + "Backtick Owner", + "CallableOwner"); + AssertReferencesContain(references, "call", null, "ownerRender"); + AssertReferencesContain(references, "instantiate", null, "Backtick Constructor"); + + var callableReference = Assert.Single(references.Where(reference => + reference.SymbolName == "backtick call" + && reference.ReferenceKind == "call")); + var callableLine = content .Split('\n') - .Single(line => line.Contains("`render name`", StringComparison.Ordinal) + .Single(line => line.Contains("`backtick call`", StringComparison.Ordinal) && line.Contains("::", StringComparison.Ordinal)); Assert.Equal( - renderLine.IndexOf("`render name`", StringComparison.Ordinal) + 1, - renderReference.Column); - Assert.Equal("`render name`".Length, renderReference.SpanLength); - Assert.DoesNotContain(references, r => r.SymbolName == "`render name`" && r.ReferenceKind == "call"); - } - - [Fact] - public void Extract_KotlinBacktickConstructorCalls_NormalizesInstantiateNames() - { - // Backticked Kotlin class names should behave like ordinary constructor calls: the - // instantiate edge uses the canonical class symbol name. - // backtick 付き Kotlin class 名の constructor call も通常の constructor call と同様に、 - // canonical class symbol 名で instantiate edge を発行する。 - const string content = """ - class `Display Name` - - class Demo { - val value = `Display Name`() - } - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(symbols, s => s.Name == "Display Name" && s.Kind == "class"); - Assert.Contains(references, r => r.SymbolName == "Display Name" && r.ReferenceKind == "instantiate"); - Assert.DoesNotContain(references, r => r.SymbolName == "`Display Name`" && r.ReferenceKind == "instantiate"); - } - - [Fact] - public void Extract_KotlinBacktickAnnotations_NormalizesNames() - { - // Kotlin annotations can be backticked declarations; metadata references should keep - // the canonical annotation symbol name for both no-arg and argument forms. - // Kotlin annotation も backtick 付き宣言にできるため、引数なし・引数ありの metadata - // reference でも canonical annotation symbol 名を使う。 - const string content = """ - annotation class `Fancy Name`(val value: String = "") - - @`Fancy Name` - class Demo { - @`Fancy Name`("x") - fun run(input: @`Fancy Name` Payload) {} - } - - class Payload - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(symbols, s => s.Name == "Fancy Name" && s.Kind == "class"); - Assert.True(references.Count(r => r.SymbolName == "Fancy Name" && r.ReferenceKind == "annotation") >= 3); - Assert.DoesNotContain(references, r => r.SymbolName == "`Fancy Name`" && r.ReferenceKind == "annotation"); - Assert.DoesNotContain(references, r => r.SymbolName == "Fancy Name" && r.ReferenceKind == "type_reference"); + callableLine.IndexOf("`backtick call`", StringComparison.Ordinal) + 1, + callableReference.Column); + Assert.Equal("`backtick call`".Length, callableReference.SpanLength); + + var annotationCount = references.Count(reference => + reference.SymbolName == "Backtick Annotation" + && reference.ReferenceKind == "annotation"); + Assert.True(annotationCount >= 3, $"Expected at least 3 Backtick Annotation references, got {annotationCount}."); + AssertReferencesDoNotContain(references, "type_reference", "Backtick Annotation"); + Assert.DoesNotContain( + references, + reference => reference.SymbolName.Contains("`", StringComparison.Ordinal)); } [Fact] @@ -1048,110 +979,65 @@ fun run(input: @receiver:Fancy Payload): @param:Fancy Payload = input } [Fact] - public void Extract_KotlinVarianceTypeArguments_DoNotBecomeTypeReferences() + public void Extract_KotlinGenericPseudoTypes_AcrossSupportedPositions_DoNotEmitTypeReferences() { + // Variance keywords and generic parameters are syntax, while their real bound/operator/ + // literal types remain dependencies. Unique names make every position independently visible. + // variance keyword と generic parameter は構文であり、実際の bound / operator / literal 型は + // 依存として残る。形式ごとの固有名により各位置を独立して観測する。 const string content = """ - interface Producer - interface Consumer - class Payload + interface VarianceProducer + interface VarianceConsumer + class VariancePayload - class Demo { - val produced: Producer? = null - val consumed: Consumer? = null + class VarianceDemo { + val produced: VarianceProducer? = null + val consumed: VarianceConsumer? = null } - """; - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + interface BoundComparable + interface BoundHandler + class BoundPayload + class BoundBox> + fun boundedRun(input: TBound): BoundHandler where TBound : BoundPayload, TBound : BoundHandler = TODO() - Assert.Contains(references, r => r.SymbolName == "Producer" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Consumer" && r.ReferenceKind == "type_reference"); - Assert.True(references.Count(r => r.SymbolName == "Payload" && r.ReferenceKind == "type_reference") >= 2); - Assert.DoesNotContain(references, r => (r.SymbolName is "in" or "out") && r.ReferenceKind == "type_reference"); - } + class OperatorUser + inline fun accepts(value: Any): Boolean = value is TOperator + fun parse(value: Any): OperatorUser = value as OperatorUser - [Fact] - public void Extract_KotlinGenericBounds_DoNotEmitTypeParameterReferences() - { - const string content = """ - interface Comparable - interface Handler - class Payload - class Box> - - fun run(input: T): Handler where T : Payload, T : Handler = TODO() + class LiteralUser + inline fun genericKClass() = TLiteral::class + fun literalUserKClass() = LiteralUser::class """; - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(references, r => r.SymbolName == "Comparable" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Payload" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Handler" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "T" && r.ReferenceKind == "type_reference"); - } - - [Fact] - public void Extract_KotlinGenericTypeOperators_DoNotEmitTypeParameterReferences() - { - const string content = """ - class User + var (_, references) = ExtractSymbolsAndReferences("kotlin", content); - inline fun accepts(value: Any): Boolean = value is T - fun parse(value: Any): User = value as User - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(references, r => r.SymbolName == "User" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "T" && r.ReferenceKind == "type_reference"); - } - - [Fact] - public void Extract_KotlinGenericClassLiterals_DoNotEmitTypeParameterReferences() - { - const string content = """ - class User - - inline fun genericKClass() = T::class - fun userKClass() = User::class - """; - - var symbols = SymbolExtractor.Extract(1, "kotlin", content); - var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); - - Assert.Contains(references, r => r.SymbolName == "User" && r.ReferenceKind == "type_reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "T" && r.ReferenceKind == "type_reference"); - } - - [Fact] - public void Extract_JavaGenericBounds_CaptureRealBoundsAndIgnoreParameterNames() - { - // Regression for issue #642: Java generic type-parameter bounds should emit the real - // bound types, including nested generic bounds, while keeping the parameter names out - // of the type_reference graph. - // issue #642 回帰: Java の generic type-parameter bounds は、ネストした generic bound - // を含めて実際の bound 型を拾いつつ、parameter 名は type_reference graph に出さないこと。 - const string content = """ - class Root {} - interface Bound {} - class Wrapper {} - - class Demo { - > U run(U value) { - return value; - } - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.Contains(references, r => r.SymbolName == "Root" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Bound" && r.ReferenceKind == "type_reference"); - Assert.Contains(references, r => r.SymbolName == "Wrapper" && r.ReferenceKind == "type_reference"); - Assert.True(references.Count(r => r.SymbolName == "Root" && r.ReferenceKind == "type_reference") >= 2); + AssertReferencesContain( + references, + "type_reference", + null, + "VarianceProducer", + "VarianceConsumer", + "BoundComparable", + "BoundPayload", + "BoundHandler", + "OperatorUser", + "LiteralUser"); + var variancePayloadCount = references.Count(reference => + reference.SymbolName == "VariancePayload" + && reference.ReferenceKind == "type_reference"); + Assert.True( + variancePayloadCount >= 2, + $"Expected at least 2 VariancePayload type references, got {variancePayloadCount}."); + AssertReferencesDoNotContain( + references, + "type_reference", + "in", + "out", + "TVariance", + "TBound", + "TOperator", + "TLiteral"); } [Fact] @@ -1226,121 +1112,155 @@ class Leaf extends Root { } [Fact] - public void Extract_JavaCtorChain_SameLineBody_RewritesToBaseClass() + public void Extract_JavaSameLineConstructors_AcrossSupportedForms_PreserveChainsAndContainers() { - // Same-line ctor bodies like `Leaf(int x){super(x);}` do not match - // SymbolExtractor's enum-member regex (line ends with `}`, not `{`/`,`/`;`), - // so no function symbol is emitted. The chain rewrite must synthesize a - // ctor container from the line text and attribute super(x)/this(0) correctly. - // 同一行に本体を書くコンストラクタは SymbolExtractor で関数シンボルが作られないため、 - // chain 書き換えは行テキストから ctor コンテナを合成して super/this を拾う必要がある。 + // Same-line Java constructors require synthetic function containers across modifiers, + // annotations, generic bounds, quoted arguments, and ordinary body calls. Form-specific + // base/leaf names keep every chain and suppression contract independently observable. + // 同一行 Java ctor は modifier、annotation、generic bound、quote 付き引数、通常 body call + // をまたいで合成 function container を必要とする。形式ごとの base / leaf 名で各契約を分離する。 const string content = """ package demo; - public class Root { - public Root(int x) {} - Root() {} + class PlainBase { + public PlainBase(int value) {} + PlainBase() {} } - - class PublicLeaf extends Root { - public PublicLeaf(int x){super(x);} - public PublicLeaf(){this(0);} + class PlainPublicLeaf extends PlainBase { + public PlainPublicLeaf(int value){super(value);} + public PlainPublicLeaf(){this(0);} } - - class PackageLeaf extends Root { - PackageLeaf(int x){super(x);} - PackageLeaf(){this(0);} + class PlainPackageLeaf extends PlainBase { + PlainPackageLeaf(int value){super(value);} + PlainPackageLeaf(){this(0);} } - """; - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - var publicSuper = Assert.Single(references, r => - r.SymbolName == "Root" && r.ContainerName == "PublicLeaf" && r.ContainerKind == "function"); - Assert.Equal("call", publicSuper.ReferenceKind); - - var publicThis = Assert.Single(references, r => - r.SymbolName == "PublicLeaf" && r.ContainerName == "PublicLeaf" && r.ContainerKind == "function"); - Assert.Equal("call", publicThis.ReferenceKind); - - var packageSuper = Assert.Single(references, r => - r.SymbolName == "Root" && r.ContainerName == "PackageLeaf" && r.ContainerKind == "function"); - Assert.Equal("call", packageSuper.ReferenceKind); - - var packageThis = Assert.Single(references, r => - r.SymbolName == "PackageLeaf" && r.ContainerName == "PackageLeaf" && r.ContainerKind == "function"); - Assert.Equal("call", packageThis.ReferenceKind); - - Assert.DoesNotContain(references, r => r.SymbolName == "super"); - Assert.DoesNotContain(references, r => r.SymbolName == "this"); - } - - [Fact] - public void Extract_JavaCtorChain_SameLineBody_WithLeadingAnnotation_RewritesToBaseClass() - { - // Same-line ctor bodies can be preceded by annotations (with or without argument - // lists), e.g. `@Deprecated Leaf(int x){super(x);}` or - // `@SuppressWarnings("unused") Leaf(int x){super(x);}`. - // The synthesis regex must accept the leading annotation so the chain rewrite still - // finds a ctor container. - // 同一行 ctor 本体の直前にアノテーションが付く形(引数あり/なし)も、合成コンテナ生成で - // 取りこぼしてはならない。 - const string content = """ - package demo; - - public class Root { - public Root(int x) {} + class LeadingBase { + LeadingBase(int value) {} + } + class LeadingLeaf extends LeadingBase { + @Deprecated LeadingLeaf(int value){super(value);} + @SuppressWarnings("unused") LeadingLeaf(long value){super((int) value);} } - class Leaf extends Root { - @Deprecated Leaf(int x){super(x);} - @SuppressWarnings("unused") Leaf(long x){super((int) x);} + class GenericCtorBase { + GenericCtorBase(int value) {} + } + class GenericCtorLeaf extends GenericCtorBase { + public GenericCtorLeaf(TGeneric value){super(0);} + GenericCtorLeaf(TBound value, int fallback){super(fallback);} } - """; - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); + @interface QualifiedAnn {} + class QualifiedBase { + QualifiedBase(int value) {} + } + class QualifiedLeaf extends QualifiedBase { + @demo.QualifiedAnn QualifiedLeaf(int value){super(value);} + @SuppressWarnings({"unused", "unchecked"}) QualifiedLeaf(long value){super((int) value);} + } - var rootRefs = references.Where(r => - r.SymbolName == "Root" && r.ContainerName == "Leaf" && r.ContainerKind == "function").ToList(); - Assert.Equal(2, rootRefs.Count); - Assert.All(rootRefs, r => Assert.Equal("call", r.ReferenceKind)); + class NestedBoundBase { + NestedBoundBase(int value) {} + } + class NestedBoundLeaf extends NestedBoundBase { + public > NestedBoundLeaf(TNested value){super(0);} + > NestedBoundLeaf(UWildcard values, int fallback){super(fallback);} + } - Assert.DoesNotContain(references, r => r.SymbolName == "super"); - Assert.DoesNotContain(references, r => r.SymbolName == "this"); - } + class ModifierBase { + ModifierBase(int value) {} + } + class ModifierLeaf extends ModifierBase { + public @Deprecated ModifierLeaf(int value){super(value);} + } - [Fact] - public void Extract_JavaCtorChain_SameLineBody_WithGenericCtor_RewritesToBaseClass() - { - // Generic constructors (`public Leaf(T x){super(0);}`) insert type parameters - // between the modifiers and the ctor name. The synthesis regex must accept the - // optional `<...>` token before ``. - // 型パラメータ付き ctor (`public Leaf(T x){super(0);}`) は修飾子と ctor 名の間に - // `<...>` が入る。合成コンテナ生成は名前直前の generic 型パラメータを許容すべし。 - const string content = """ - package demo; + @interface QuotedAnn { + String text(); + } + class QuotedBase { + QuotedBase(int value) {} + } + class QuotedLeaf extends QuotedBase { + public @QuotedAnn(text=")") QuotedLeaf(){super(0);} + } - public class Root { - public Root(int x) {} + class BodyHelper { + static void doBodyWork() {} + } + class BodyBase { + BodyBase(int value) {} + } + class BodyLeaf extends BodyBase { + public BodyLeaf(TBody value){super(0); BodyHelper.doBodyWork();} } - class Leaf extends Root { - public Leaf(T x){super(0);} - Leaf(T x, int y){super(y);} + class SelfBase { + SelfBase(int value) {} + } + class SelfLeaf extends SelfBase { + SelfLeaf(){super(0);} } """; - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); + var (_, references) = ExtractSymbolsAndReferences("java", content); - var rootRefs = references.Where(r => - r.SymbolName == "Root" && r.ContainerName == "Leaf" && r.ContainerKind == "function").ToList(); - Assert.Equal(2, rootRefs.Count); - Assert.All(rootRefs, r => Assert.Equal("call", r.ReferenceKind)); + AssertExactConstructorEdges("PlainBase", "PlainPublicLeaf", 1); + AssertExactConstructorEdges("PlainPublicLeaf", "PlainPublicLeaf", 1); + AssertExactConstructorEdges("PlainBase", "PlainPackageLeaf", 1); + AssertExactConstructorEdges("PlainPackageLeaf", "PlainPackageLeaf", 1); + AssertExactConstructorEdges("LeadingBase", "LeadingLeaf", 2); + AssertExactConstructorEdges("GenericCtorBase", "GenericCtorLeaf", 2); + AssertExactConstructorEdges("QualifiedBase", "QualifiedLeaf", 2); + AssertExactConstructorEdges("NestedBoundBase", "NestedBoundLeaf", 2); - Assert.DoesNotContain(references, r => r.SymbolName == "super"); + var modifierLine = Array.FindIndex( + content.Split('\n'), + line => line.Contains("public @Deprecated ModifierLeaf", StringComparison.Ordinal)) + 1; + Assert.True(modifierLine > 0); + Assert.Contains(references, reference => + reference.SymbolName == "ModifierBase" + && reference.ReferenceKind == "call" + && reference.ContainerKind == "function" + && reference.ContainerName == "ModifierLeaf" + && reference.Line == modifierLine); + Assert.Contains(references, reference => + reference.SymbolName == "QuotedBase" + && reference.ReferenceKind == "call" + && reference.ContainerKind == "function" + && reference.ContainerName == "QuotedLeaf"); + Assert.Contains(references, reference => + reference.SymbolName == "BodyBase" + && reference.ReferenceKind == "call" + && reference.ContainerKind == "function" + && reference.ContainerName == "BodyLeaf"); + Assert.Contains(references, reference => + reference.SymbolName == "doBodyWork" + && reference.ReferenceKind == "call" + && reference.ContainerKind == "function" + && reference.ContainerName == "BodyLeaf"); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "doBodyWork" + && reference.ContainerKind == "class"); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "SelfLeaf" + && reference.ReferenceKind == "call"); + Assert.Contains(references, reference => + reference.SymbolName == "SelfBase" + && reference.ReferenceKind == "call" + && reference.ContainerKind == "function" + && reference.ContainerName == "SelfLeaf"); + Assert.DoesNotContain(references, reference => reference.SymbolName is "super" or "this"); + + void AssertExactConstructorEdges(string targetName, string containerName, int expectedCount) + { + var edges = references.Where(reference => + reference.SymbolName == targetName + && reference.ContainerName == containerName + && reference.ContainerKind == "function").ToList(); + Assert.Equal(expectedCount, edges.Count); + Assert.All(edges, edge => Assert.Equal("call", edge.ReferenceKind)); + } } [Fact] @@ -1396,68 +1316,6 @@ class Leaf extends Outer.Base { Assert.DoesNotContain(references, r => r.SymbolName == "super"); } - [Fact] - public void Extract_JavaCtorChain_SameLineBody_WithQualifiedAnnotation_RewritesToBaseClass() - { - // Annotations on same-line ctors can be fully qualified (`@demo.Ann`) or carry - // nested-paren argument lists. The synthesis scanner must strip both before locating - // the ctor name. - // 同一行 ctor 本体のアノテーションは `@demo.Ann` のような FQCN や、入れ子の括弧付き - // 引数を持つこともある。合成コンテナ生成は両方を剥がして ctor 名へ辿り着く必要がある。 - const string content = """ - package demo; - - public class Root { - public Root(int x) {} - } - - @interface Ann {} - - class Leaf extends Root { - @demo.Ann Leaf(int x){super(x);} - @SuppressWarnings({"unused", "unchecked"}) Leaf(long x){super((int) x);} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - var rootRefs = references.Where(r => - r.SymbolName == "Root" && r.ContainerName == "Leaf" && r.ContainerKind == "function").ToList(); - Assert.Equal(2, rootRefs.Count); - Assert.All(rootRefs, r => Assert.Equal("call", r.ReferenceKind)); - } - - [Fact] - public void Extract_JavaCtorChain_SameLineBody_WithNestedGenericBound_RewritesToBaseClass() - { - // Generic type parameters can carry nested `<...>` bounds such as - // `>`. A flat regex cannot balance the nested `>`; the - // synthesis scanner must handle it. - // `>` のような入れ子 `<...>` を伴う generic 境界も - // 合成コンテナ生成で取りこぼしてはならない。 - const string content = """ - package demo; - - public class Root { - public Root(int x) {} - } - - class Leaf extends Root { - public > Leaf(T x){super(0);} - > Leaf(U xs, int y){super(y);} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - var rootRefs = references.Where(r => - r.SymbolName == "Root" && r.ContainerName == "Leaf" && r.ContainerKind == "function").ToList(); - Assert.Equal(2, rootRefs.Count); - Assert.All(rootRefs, r => Assert.Equal("call", r.ReferenceKind)); - } - [Fact] public void Extract_JavaSuperCall_SealedWithPermits_AttributesToRealBase() { @@ -1882,34 +1740,6 @@ throws IoFailure Assert.DoesNotContain(references, r => r.SymbolName == "super"); } - [Fact] - public void Extract_JavaSuperCall_SameLineCtorWithModifierThenAnnotation_AttributesToRealBase() - { - // Regression for same-line ctor bodies where an access modifier precedes an annotation, - // e.g. `public @Deprecated Leaf(...)`. Before the fix the scanner consumed the modifier - // first, hit `@` in ConsumeIdentifier, and returned null, dropping the super(...) edge. - // 修正前は modifier の後に annotation が来ると ctor 名抽出が失敗し、super(...) が落ちた。 - const string content = """ - package demo; - - class Root { - Root(int value) {} - } - - class Leaf extends Root { - public @Deprecated Leaf(int x){super(x);} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.Contains(references, r => - r.SymbolName == "Root" && r.ReferenceKind == "call" - && r.ContainerKind == "function" && r.ContainerName == "Leaf" && r.Line == 8); - Assert.DoesNotContain(references, r => r.SymbolName == "super"); - } - [Fact] public void Extract_JavaSuperChain_BraceAnnotationArgExtends_AttributesSuperEdgeEndToEnd() { @@ -2052,114 +1882,6 @@ class Leaf extends @Ann(text=")") Root { && r.ContainerKind == "function" && r.ContainerName == "Leaf"); } - [Fact] - public void Extract_Java_SameLineCtorWithQuotedAnnotationArg_KeepsSuperEdge() - { - // Regression: TryExtractJavaCtorNameFromLine walks past annotations using - // SkipBalancedParens, which previously counted raw `)` characters. A legal - // `@Ann(text=")")` prefix on a same-line ctor truncated annotation scanning at the - // string's closing `)` and the ctor name read then started mid-string, so - // TrySynthesizeSameLineJavaCtor returned null and the synthesized `super(...)` edge - // added for same-line ctors vanished. - // 同一行 ctor の annotation 文字列引数内 `)` が ctor 名抽出を壊さないことを固定する。 - const string content = """ - package demo; - - class Root { - Root(int value) {} - } - - @interface Ann { - String text(); - } - - class Leaf extends Root { - public @Ann(text=")") Leaf(){super(0);} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.Contains(references, r => - r.SymbolName == "Root" && r.ReferenceKind == "call" - && r.ContainerKind == "function" && r.ContainerName == "Leaf"); - } - - [Fact] - public void Extract_Java_SameLineCtorBodyCall_AttributesToCtorContainer() - { - // Regression: non-chain body calls on a same-line Java ctor (for example the - // `Helper.doWork()` statement after `super(0);` in `Leaf(T x){super(0); Helper.doWork();}`) - // previously landed on `class:Leaf` because SymbolExtractor does not emit a function - // symbol for the same-line ctor shape. The main loop now pre-computes a per-line synthetic - // function-kind container covering the body `{ ... }` span so body calls attribute to - // `function:Leaf` instead of leaking to the enclosing class. - // 同一行 ctor 本体の通常 call が外側 class に吸われず、合成 function コンテナに帰属することを固定する。 - const string content = """ - package demo; - - class Helper { - static void doWork() {} - } - - class Root { - Root(int v) {} - } - - class Leaf extends Root { - public Leaf(T x){super(0); Helper.doWork();} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.Contains(references, r => - r.SymbolName == "doWork" && r.ReferenceKind == "call" - && r.ContainerKind == "function" && r.ContainerName == "Leaf"); - Assert.DoesNotContain(references, r => - r.SymbolName == "doWork" && r.ContainerKind == "class"); - - // Chain edge remains on the synthetic function ctor too. - // 連鎖エッジも合成 function コンテナに帰属したままであることを確認する。 - Assert.Contains(references, r => - r.SymbolName == "Root" && r.ReferenceKind == "call" - && r.ContainerKind == "function" && r.ContainerName == "Leaf"); - } - - [Fact] - public void Extract_Java_SameLineCtorDeclarator_DoesNotEmitSelfCall() - { - // Regression: `CallRegex` matches `CtorName(` on the declarator of a same-line Java ctor - // and, without suppression, emitted a phantom `Leaf|call|class|Leaf` edge attributing the - // declarator to the enclosing class. The main loop now skips the `CtorName(` match at the - // declarator's name column when the current line carries a synthesized same-line ctor. - // 同一行 ctor の宣言子 `CtorName(` が自己 call として記録されないことを固定する。 - const string content = """ - package demo; - - class Root { - Root(int v) {} - } - - class Leaf extends Root { - Leaf(){super(0);} - } - """; - - var symbols = SymbolExtractor.Extract(1, "java", content); - var references = ReferenceExtractor.Extract(1, "java", content, symbols); - - Assert.DoesNotContain(references, r => - r.SymbolName == "Leaf" && r.ReferenceKind == "call"); - // But the chain rewrite still emits the `Root` edge attributed to `function:Leaf`. - // 連鎖書き換えによる `Root` エッジは残っていることを確認する。 - Assert.Contains(references, r => - r.SymbolName == "Root" && r.ReferenceKind == "call" - && r.ContainerKind == "function" && r.ContainerName == "Leaf"); - } - [Fact] public void Extract_Java_ModuleInfoDirectives_EmitModuleDependencyReferences() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorPerformanceBudgetTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorPerformanceBudgetTests.cs new file mode 100644 index 000000000..fa93eab7a --- /dev/null +++ b/tests/CodeIndex.Tests/ReferenceExtractorPerformanceBudgetTests.cs @@ -0,0 +1,85 @@ +using System.Diagnostics; +using System.Text; +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +[CollectionDefinition(ReferenceExtractorPerformanceBudgetCollection.Name, DisableParallelization = true)] +public sealed class ReferenceExtractorPerformanceBudgetCollection +{ + public const string Name = "Reference extractor performance budget"; +} + +[Collection(ReferenceExtractorPerformanceBudgetCollection.Name)] +public sealed class ReferenceExtractorPerformanceBudgetTests +{ +#if NET8_0 + [Fact] +#else + [Fact(Skip = PracticalBudgetTestTarget.SecondaryTargetSkipReason)] +#endif + public void Extract_CSharpLargePlainCallFile_CompletesWithinPracticalBudget() + { + ReferenceExtractorWarmup.EnsurePerformanceWarmup(); + + const int callerCount = 500; + var builder = new StringBuilder(); + builder.AppendLine("class App {"); + builder.AppendLine(" void Target() { }"); + for (var index = 0; index < callerCount; index++) + builder.Append(" void Caller").Append(index).AppendLine("() { Target(); }"); + builder.AppendLine("}"); + var content = builder.ToString(); + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var stopwatch = Stopwatch.StartNew(); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + stopwatch.Stop(); + + Assert.Contains(references, reference => reference.SymbolName == "Target" && reference.ContainerName == "Caller0"); + Assert.Contains(references, reference => reference.SymbolName == "Target" && reference.ContainerName == $"Caller{callerCount - 1}"); + var runawayBudget = TimeSpan.FromSeconds(5); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large C# plain call reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } + +#if NET8_0 + [Fact] +#else + [Fact(Skip = PracticalBudgetTestTarget.SecondaryTargetSkipReason)] +#endif + public void Extract_CSharpLargeMethodWithManyLocals_CompletesWithinPracticalBudget() + { + ReferenceExtractorWarmup.EnsurePerformanceWarmup(); + + const int localCount = 1_000; + var builder = new StringBuilder(); + builder.AppendLine("class Demo"); + builder.AppendLine("{"); + builder.AppendLine(" int Run(int input)"); + builder.AppendLine(" {"); + builder.AppendLine(" var result = input;"); + for (var i = 0; i < localCount; i++) + { + builder.Append(" var value").Append(i).Append(" = result + ").Append(i).AppendLine(";"); + builder.Append(" result += value").Append(i).AppendLine(";"); + } + builder.AppendLine(" return Helper(result);"); + builder.AppendLine(" }"); + builder.AppendLine(" int Helper(int value) => value;"); + builder.AppendLine("}"); + var content = builder.ToString(); + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var stopwatch = Stopwatch.StartNew(); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + stopwatch.Stop(); + + Assert.Contains(references, reference => reference.SymbolName == "Helper" && reference.ReferenceKind == "call"); + var runawayBudget = TimeSpan.FromSeconds(5); + Assert.True( + stopwatch.Elapsed < runawayBudget, + $"Large C# method reference extraction took {stopwatch.Elapsed.TotalSeconds:F2}s, expected < {runawayBudget.TotalSeconds:F0}s runaway guard budget."); + } +} diff --git a/tests/CodeIndex.Tests/ReferenceExtractorProductionCoverageTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorProductionCoverageTests.cs index e5619ea3a..4ccbaab98 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorProductionCoverageTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorProductionCoverageTests.cs @@ -6,47 +6,16 @@ namespace CodeIndex.Tests; public class SwiftReferenceExtractorTests { [Fact] - public void Extract_Swift_BasicCall_IsReferenced() + public void Extract_Swift_ProductionCoverage_HasExpectedPlacements() { - var references = Extract(""" + var references = ReferenceCoverage.Extract("swift", """ func login() { authenticate() } - """); - - AssertCall(references, "authenticate"); - } - - [Fact] - public void Extract_Swift_QualifiedCall_UsesInvokedMemberName() - { - var references = Extract(""" - func run() { - ServiceFactory.shared.makeClient() - } - """); - - AssertCall(references, "makeClient"); - } - - [Fact] - public void Extract_Swift_MethodCallOnChain_IsReferenced() - { - var references = Extract(""" func run(items: [Item]) { + ServiceFactory.shared.makeClient() items.publisher().compactMap(transform).sink(receiveValue: save) } - """); - - AssertCall(references, "publisher"); - AssertCall(references, "compactMap"); - AssertCall(references, "sink"); - } - - [Fact] - public void Extract_Swift_TypePositions_AreTypeReferences() - { - var references = Extract(""" func handle(value: Payload) -> ResultWrapper { let model: UserModel = load() if model is PremiumUser { @@ -54,102 +23,43 @@ func handle(value: Payload) -> ResultWrapper { } return ResultWrapper() } - """); - - AssertTypeReference(references, "Payload"); - AssertTypeReference(references, "ResultWrapper"); - AssertTypeReference(references, "UserModel"); - AssertTypeReference(references, "PremiumUser"); - } - - [Fact] - public void Extract_Swift_CommentsAndDeclarations_DoNotEmitCalls() - { - var references = Extract(""" func declaredOnly() {} // ignoredCall() let value = "fakeCall()" """); - Assert.DoesNotContain(references, r => r.SymbolName == "declaredOnly" && r.ReferenceKind == "call"); - Assert.DoesNotContain(references, r => r.SymbolName == "ignoredCall"); - Assert.DoesNotContain(references, r => r.SymbolName == "fakeCall"); - } + ReferenceCoverage.AssertPlacement(references, "authenticate", "call", 2, "authenticate()", "function", "login"); + ReferenceCoverage.AssertPlacement(references, "makeClient", "call", 5, "ServiceFactory.shared.makeClient()", "function", "run"); + ReferenceCoverage.AssertPlacement(references, "publisher", "call", 6, "items.publisher().compactMap(transform).sink(receiveValue: save)", "function", "run"); + ReferenceCoverage.AssertPlacement(references, "compactMap", "call", 6, "items.publisher().compactMap(transform).sink(receiveValue: save)", "function", "run"); + ReferenceCoverage.AssertPlacement(references, "sink", "call", 6, "items.publisher().compactMap(transform).sink(receiveValue: save)", "function", "run"); + ReferenceCoverage.AssertPlacement(references, "Payload", "type_reference", 8, "func handle(value: Payload) -> ResultWrapper {", "function", "handle"); + ReferenceCoverage.AssertPlacement(references, "ResultWrapper", "type_reference", 8, "func handle(value: Payload) -> ResultWrapper {", "function", "handle"); + ReferenceCoverage.AssertPlacement(references, "UserModel", "type_reference", 9, "let model: UserModel = load()", "function", "handle"); + ReferenceCoverage.AssertPlacement(references, "PremiumUser", "type_reference", 10, "if model is PremiumUser {", "function", "handle"); - private static IReadOnlyList Extract(string content) - { - var symbols = SymbolExtractor.Extract(1, "swift", content); - return ReferenceExtractor.Extract(1, "swift", content, symbols); + ReferenceCoverage.AssertAbsent(references, "declaredOnly", "call"); + ReferenceCoverage.AssertAbsent(references, "ignoredCall"); + ReferenceCoverage.AssertAbsent(references, "fakeCall"); } - - private static void AssertCall(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "call"); - - private static void AssertTypeReference(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "type_reference"); } public class ObjectiveCReferenceExtractorTests { [Fact] - public void Extract_ObjectiveC_CFunctionCall_IsReferenced() + public void Extract_ObjectiveC_ProductionCoverage_HasExpectedPlacements() { - var references = Extract(""" + var references = ReferenceCoverage.Extract("objc", """ void Run(void) { CFRelease(token); - } - """); - - AssertCall(references, "CFRelease"); - } - - [Fact] - public void Extract_ObjectiveC_ClassMessage_IsReferenced() - { - var references = Extract(""" - void Run(void) { - id client = [HTTPClient sharedClient]; - } - """); - - AssertCall(references, "sharedClient"); - } - - [Fact] - public void Extract_ObjectiveC_ChainedMessage_IsReferenced() - { - var references = Extract(""" - void Run(void) { id client = [HTTPClient sharedClient]; id request = [client requestBuilder]; [request send]; } - """); - - AssertCall(references, "sharedClient"); - AssertCall(references, "requestBuilder"); - AssertCall(references, "send"); - } - - [Fact] - public void Extract_ObjectiveC_TypePositions_AreTypeReferences() - { - var references = Extract(""" @interface Controller : BaseController @property (nonatomic, strong) UserModel *model; - (Result *)handle:(Payload *)payload; @end - """); - - AssertTypeReference(references, "BaseController"); - AssertTypeReference(references, "ControllerDelegate"); - AssertTypeReference(references, "UserModel"); - } - - [Fact] - public void Extract_ObjectiveC_CommentsAndDeclarations_DoNotEmitCalls() - { - var references = Extract(""" @interface Service - (void)declaredOnly; @end @@ -157,323 +67,199 @@ @interface Service NSString *text = @"fakeCall()"; """); - Assert.DoesNotContain(references, r => r.SymbolName == "declaredOnly" && r.ReferenceKind == "call"); - Assert.DoesNotContain(references, r => r.SymbolName == "ignoredCall"); - Assert.DoesNotContain(references, r => r.SymbolName == "fakeCall"); - } + ReferenceCoverage.AssertPlacement(references, "CFRelease", "call", 2, "CFRelease(token);", null, null); + ReferenceCoverage.AssertPlacement(references, "sharedClient", "call", 3, "id client = [HTTPClient sharedClient];", null, null); + ReferenceCoverage.AssertPlacement(references, "requestBuilder", "call", 4, "id request = [client requestBuilder];", null, null); + ReferenceCoverage.AssertPlacement(references, "send", "call", 5, "[request send];", null, null); + ReferenceCoverage.AssertPlacement(references, "BaseController", "type_reference", 7, "@interface Controller : BaseController ", null, null); + ReferenceCoverage.AssertPlacement(references, "ControllerDelegate", "type_reference", 7, "@interface Controller : BaseController ", null, null); + ReferenceCoverage.AssertPlacement(references, "UserModel", "type_reference", 8, "@property (nonatomic, strong) UserModel *model;", null, null); - private static IReadOnlyList Extract(string content) - { - var symbols = SymbolExtractor.Extract(1, "objc", content); - return ReferenceExtractor.Extract(1, "objc", content, symbols); + ReferenceCoverage.AssertAbsent(references, "declaredOnly", "call"); + ReferenceCoverage.AssertAbsent(references, "ignoredCall"); + ReferenceCoverage.AssertAbsent(references, "fakeCall"); } - - private static void AssertCall(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "call"); - - private static void AssertTypeReference(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "type_reference"); } public class GradleReferenceExtractorTests { [Fact] - public void Extract_Gradle_BlockDslCall_IsReferenced() + public void Extract_Gradle_ProductionCoverage_HasExpectedPlacements() { - var references = Extract(""" + var references = ReferenceCoverage.Extract("gradle", """ plugins { id 'java' } - """); - - AssertCall(references, "plugins"); - } - - [Fact] - public void Extract_Gradle_CommandDslCall_IsReferenced() - { - var references = Extract(""" apply plugin: 'java' - """); - - AssertCall(references, "apply"); - } - - [Fact] - public void Extract_Gradle_TaskWithTypeArgument_IsReferenced() - { - var references = Extract(""" task buildJar(type: Jar) { dependsOn compileJava } - """); - - AssertCall(references, "task"); - } - - [Fact] - public void Extract_Gradle_MethodCallOnChain_IsReferenced() - { - var references = Extract(""" dependencies { implementation project(':core') configurations.runtimeClasspath.get().files() } - """); - - AssertCall(references, "dependencies"); - AssertCall(references, "implementation"); - AssertCall(references, "project"); - AssertCall(references, "get"); - AssertCall(references, "files"); - } - - [Fact] - public void Extract_Gradle_AssignmentsAndComments_DoNotEmitCalls() - { - var references = Extract(""" version = '1.0' group = 'demo' // ignoredCall() """); - Assert.DoesNotContain(references, r => r.SymbolName == "version" && r.ReferenceKind == "call"); - Assert.DoesNotContain(references, r => r.SymbolName == "group" && r.ReferenceKind == "call"); - Assert.DoesNotContain(references, r => r.SymbolName == "ignoredCall"); - } + ReferenceCoverage.AssertPlacement(references, "plugins", "call", 1, "plugins {", null, null); + ReferenceCoverage.AssertPlacement(references, "apply", "call", 4, "apply plugin: 'java'", null, null); + ReferenceCoverage.AssertPlacement(references, "task", "call", 5, "task buildJar(type: Jar) {", "function", "buildJar"); + ReferenceCoverage.AssertPlacement(references, "dependencies", "call", 8, "dependencies {", null, null); + ReferenceCoverage.AssertPlacement(references, "implementation", "call", 9, "implementation project(':core')", null, null); + ReferenceCoverage.AssertPlacement(references, "project", "call", 9, "implementation project(':core')", null, null); + ReferenceCoverage.AssertPlacement(references, "get", "call", 10, "configurations.runtimeClasspath.get().files()", null, null); + ReferenceCoverage.AssertPlacement(references, "files", "call", 10, "configurations.runtimeClasspath.get().files()", null, null); - private static IReadOnlyList Extract(string content) - { - var symbols = SymbolExtractor.Extract(1, "gradle", content); - return ReferenceExtractor.Extract(1, "gradle", content, symbols); + ReferenceCoverage.AssertAbsent(references, "version", "call"); + ReferenceCoverage.AssertAbsent(references, "group", "call"); + ReferenceCoverage.AssertAbsent(references, "ignoredCall"); } - - private static void AssertCall(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "call"); } public class TerraformReferenceExtractorTests { [Fact] - public void Extract_Terraform_VariableReference_IsReferenced() + public void Extract_Terraform_ProductionCoverage_HasExpectedPlacements() { - var references = Extract(""" + var references = ReferenceCoverage.Extract("terraform", """ variable "region" {} - output "region" { - value = var.region - } - """); - - AssertReference(references, "region"); - } - - [Fact] - public void Extract_Terraform_ModuleReference_IsReferenced() - { - var references = Extract(""" + variable "unused_region" {} module "network" { source = "./network" } + resource "aws_instance" "web" {} + resource "aws_s3_bucket" "logs" {} + data "aws_ami" "ubuntu" {} + output "region_value" { + value = var.region + } output "subnet" { value = module.network.subnet_id } - """); - - AssertReference(references, "network"); - } - - [Fact] - public void Extract_Terraform_ResourceReference_IsReferenced() - { - var references = Extract(""" - resource "aws_instance" "web" {} output "id" { value = aws_instance.web.id } - """); - - AssertReference(references, "web"); - } - - [Fact] - public void Extract_Terraform_DataReference_IsReferenced() - { - var references = Extract(""" - data "aws_ami" "ubuntu" {} output "ami" { value = data.aws_ami.ubuntu.id } - """); - - AssertReference(references, "ubuntu"); - } - - [Fact] - public void Extract_Terraform_DefinitionsAndComments_DoNotEmitReferences() - { - var references = Extract(""" - variable "region" {} - resource "aws_s3_bucket" "logs" {} # var.ignored output "literal" { value = "module.fake" } """); - Assert.DoesNotContain(references, r => r.SymbolName == "region" && r.ReferenceKind == "reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "logs" && r.ReferenceKind == "reference"); - Assert.DoesNotContain(references, r => r.SymbolName == "ignored"); - Assert.DoesNotContain(references, r => r.SymbolName == "fake"); - } + ReferenceCoverage.AssertPlacement(references, "region", "reference", 10, "value = var.region", "function", "region_value"); + ReferenceCoverage.AssertPlacement(references, "network", "reference", 13, "value = module.network.subnet_id", "function", "subnet"); + ReferenceCoverage.AssertPlacement(references, "web", "reference", 16, "value = aws_instance.web.id", "function", "id"); + ReferenceCoverage.AssertPlacement(references, "ubuntu", "reference", 19, "value = data.aws_ami.ubuntu.id", "function", "ami"); - private static IReadOnlyList Extract(string content) - { - var symbols = SymbolExtractor.Extract(1, "terraform", content); - return ReferenceExtractor.Extract(1, "terraform", content, symbols); + ReferenceCoverage.AssertAbsent(references, "unused_region", "reference"); + ReferenceCoverage.AssertAbsent(references, "logs", "reference"); + ReferenceCoverage.AssertAbsent(references, "ignored"); + ReferenceCoverage.AssertAbsent(references, "fake"); } - - private static void AssertReference(IReadOnlyCollection references, string symbolName) - => Assert.Contains(references, r => r.SymbolName == symbolName && r.ReferenceKind == "reference"); } public class PowerShellReferenceExtractorTests { [Fact] - public void Extract_PowerShell_StatementStartCall_IsReferenced() + public void Extract_PowerShell_ProductionCoverage_HasExpectedPlacements() { - var references = Extract(""" + var references = ReferenceCoverage.Extract("powershell", """ Write-Host "hello" - """); - - AssertCall(references, "Write-Host"); - } - - [Fact] - public void Extract_PowerShell_PipelineCall_IsReferenced() - { - var references = Extract(""" $items | ForEach-Object { Process-One $_ } - """); - - AssertCall(references, "ForEach-Object"); - AssertCall(references, "Process-One"); - } - - [Fact] - public void Extract_PowerShell_AssignmentCall_IsReferenced() - { - var references = Extract(""" $result = Invoke-RestMethod -Uri $Uri - """); - - AssertCall(references, "Invoke-RestMethod"); - } - - [Fact] - public void Extract_PowerShell_ChainedPipelineCalls_AreReferenced() - { - var references = Extract(""" $items | Where-Object { $_.Enabled } | Select-Object Name - """); - - AssertCall(references, "Where-Object"); - AssertCall(references, "Select-Object"); - } - - [Fact] - public void Extract_PowerShell_OperatorsAndComments_DoNotEmitCalls() - { - var references = Extract(""" - # Write-Host "ignored" + # Ignored-Command "ignored" if ($count -lt 10) { return } $name = "Fake-Call" """); - Assert.DoesNotContain(references, r => r.SymbolName == "Write-Host"); - Assert.DoesNotContain(references, r => r.SymbolName == "lt"); - Assert.DoesNotContain(references, r => r.SymbolName == "Fake-Call"); - } + ReferenceCoverage.AssertPlacement(references, "Write-Host", "call", 1, "Write-Host \"hello\"", "function", "