From 64a58ff626e7236b57a1b6840c63cba72559fdbf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 07:33:55 +0900 Subject: [PATCH 01/22] Consolidate language alias catalog tests --- TESTING_GUIDE.md | 2 + .../QueryCommandRunnerTests.cs | 125 +++--------------- 2 files changed, 23 insertions(+), 104 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6e809b867..090fe5f91 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -331,6 +331,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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. + 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. 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. @@ -1296,6 +1297,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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を反復してください。 + language-alias catalog coverageはcanonical languageごとに1回だけqueryし、期待aliasを1つのfact内で反復してください。言語追加のたびに同一のdiscovery / assertion setupを増やさないようにします。 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 を直接比較できるようにしてください。 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 9a94e3c60..3c4938fb3 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] From 87d8603f8964fd7761ebc4e18ca5fd9cb0c4499a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 07:48:24 +0900 Subject: [PATCH 02/22] Reuse built artifact in MCP smoke task --- DEVELOPER_GUIDE.md | 4 ++-- TESTING_GUIDE.md | 2 ++ dev.sh | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9386c348b..da161032c 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. @@ -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` などで実行します。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 090fe5f91..07fee98c4 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -50,6 +50,7 @@ Use the full suite by default. Use targeted filters only while iterating locally 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. + 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. - 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. @@ -1013,6 +1014,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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 で再インストールしないようにします。 + ローカルの `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 だけを受け取れるようにします。 - coverage collection は coverage が有効な各 shard の初回 test attempt だけで実行し、flaky classification の1回だけの retry では同じ test 引数を再利用しつつ coverage collector を再実行しないでください。 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" From a3072604fc02a316333dd75118dc91abf5839b65 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 07:54:34 +0900 Subject: [PATCH 03/22] Streamline release runner setup and caches --- .github/workflows/release.yml | 36 +++++++++++-------- TESTING_GUIDE.md | 2 ++ tests/CodeIndex.Tests/CiWorkflowTests.cs | 23 ++++++++---- tests/CodeIndex.Tests/ReleaseWorkflowTests.cs | 19 ++++++++++ 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7921323ee..7bf7a7a41 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 }}" @@ -161,21 +161,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 +825,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/TESTING_GUIDE.md b/TESTING_GUIDE.md index 07fee98c4..5c3456cd6 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -57,6 +57,7 @@ Use the full suite by default. Use targeted filters only while iterating locally - 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. @@ -1021,6 +1022,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - 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 再評価を避けてください。 diff --git a/tests/CodeIndex.Tests/CiWorkflowTests.cs b/tests/CodeIndex.Tests/CiWorkflowTests.cs index 6f685f2e5..5a6842799 100644 --- a/tests/CodeIndex.Tests/CiWorkflowTests.cs +++ b/tests/CodeIndex.Tests/CiWorkflowTests.cs @@ -245,14 +245,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,11 +408,15 @@ 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'"); + + if (cacheBlock.Text.Contains("~/.nuget/packages", StringComparison.Ordinal)) + { + AssertContainsAll( + cacheBlock.Text, + StringComparison.Ordinal, + "hashFiles('**/packages.lock.json', 'global.json')"); + } } AssertContainsAll( diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index dc5b48805..9d27eeb9d 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -140,7 +140,14 @@ public void ReleaseWorkflow_GeneratesCycloneDxSbomAndShipsItAsReleaseAsset() // 系のモダンフラグを備える。 AssertContainsAll( workflow, + "Cache CycloneDX SBOM tool (linux-x64 only)", + "id: cyclonedx-tool-cache", + "path: ~/.dotnet/tools", + "key: ${{ runner.os }}-cyclonedx-6.2.0", + "steps.cyclonedx-tool-cache.outputs.cache-hit != 'true'", "dotnet tool install --global CycloneDX --version 6.2.0", + "Add CycloneDX SBOM tool to PATH (linux-x64 only)", + "echo \"$HOME/.dotnet/tools\" >> \"$GITHUB_PATH\"", "dotnet-CycloneDX src/CodeIndex/CodeIndex.csproj", "--output-format Json", "--exclude-test-projects", @@ -270,9 +277,21 @@ public void ReleaseWorkflow_UsesChangelogToolForTemplatedReleaseNotes() public void ReleaseWorkflow_NormalizesNuGetCorePropertiesBeforePublishing() { var workflow = ReadReleaseWorkflow(); + const string publishNuGetCache = + "- name: Set up .NET\n" + + " uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0\n" + + " with:\n" + + " dotnet-version: |\n" + + " 8.0.413\n" + + " 9.0.301\n" + + " cache: true\n" + + " cache-dependency-path: |\n" + + " src/CodeIndex/packages.lock.json\n" + + " tools/CodeIndex.PackageNormalize/packages.lock.json"; AssertContainsAll( workflow, + publishNuGetCache, "Normalize NuGet package metadata part names", "dotnet run --project tools/CodeIndex.PackageNormalize --", "nupkg/*.nupkg nupkg/*.snupkg", From 2026608b4ea1666b4a946cfc307367d03c04815c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 08:13:04 +0900 Subject: [PATCH 04/22] Consolidate languages catalog coverage --- TESTING_GUIDE.md | 2 + .../QueryCommandRunnerTests.cs | 322 ++++++------------ 2 files changed, 111 insertions(+), 213 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5c3456cd6..bb64c0aed 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -334,6 +334,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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. 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. + 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. @@ -1302,6 +1303,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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を反復してください。 language-alias catalog coverageはcanonical languageごとに1回だけqueryし、期待aliasを1つのfact内で反復してください。言語追加のたびに同一のdiscovery / assertion setupを増やさないようにします。 + 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 を直接比較できるようにしてください。 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 3c4938fb3..3cb2b0231 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -3413,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() { @@ -3588,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() { @@ -3882,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() { @@ -3951,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" }) { From 5595ffdfe5d0e9439539645a67d9082d04528358 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 08:25:34 +0900 Subject: [PATCH 05/22] Avoid redundant PR and coverage artifacts --- .github/workflows/dotnet.yml | 7 ++++--- TESTING_GUIDE.md | 2 ++ tests/CodeIndex.Tests/CiWorkflowTests.cs | 12 +++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 703cad093..eefa8e836 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -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/TESTING_GUIDE.md b/TESTING_GUIDE.md index bb64c0aed..ef8c9f338 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -692,6 +692,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. @@ -1654,6 +1655,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` diff --git a/tests/CodeIndex.Tests/CiWorkflowTests.cs b/tests/CodeIndex.Tests/CiWorkflowTests.cs index 5a6842799..fc6a2e01d 100644 --- a/tests/CodeIndex.Tests/CiWorkflowTests.cs +++ b/tests/CodeIndex.Tests/CiWorkflowTests.cs @@ -167,6 +167,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 +177,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 +193,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); } From ff0c1e92a6b0d34d7a00e39e3c9e50d99df6a455 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 08:42:25 +0900 Subject: [PATCH 06/22] Consolidate search language alias fixtures --- TESTING_GUIDE.md | 8 +- .../QueryCommandRunnerSearchTests.cs | 347 ++---------------- 2 files changed, 39 insertions(+), 316 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ef8c9f338..674539b3c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -332,7 +332,7 @@ 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 preserve cross-language filter 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. 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. @@ -340,10 +340,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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. 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. @@ -1302,7 +1300,7 @@ 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でcross-language filter isolationを維持し、異なるspelling/casing aliasは1回だけ反復し、Ruby/F#のexact-search coverageも保持してください。 language-alias catalog coverageはcanonical languageごとに1回だけqueryし、期待aliasを1つのfact内で反復してください。言語追加のたびに同一のdiscovery / assertion setupを増やさないようにします。 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を再利用してください。 @@ -1311,10 +1309,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 共通 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内で反復してください。 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を反復してください。 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index fd39828b8..216abff51 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -12749,287 +12749,50 @@ public void RunSearch_RecognizesMsbuildProjectFiles() } [Fact] - public void RunSearch_RecognizesXamlLanguageAliases() + public void RunSearch_NormalizesLanguageAliasesAcrossSharedIndex() { - 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() - { - 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", "--count"] + : [testCase.Query, "--db", dbPath, "--lang", alias, "--count"]; var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - [queryToken, "--db", dbPath, "--lang", input, "--count"], + args, _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); @@ -13037,10 +12800,6 @@ public void RunSearch_NormalizesSqlDialectLangAliases() Assert.Equal(string.Empty, stderr); } } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } } [Fact] @@ -13182,38 +12941,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() { From babd2204b1d4fbb97f1bfa597e807b207529dd48 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 08:49:54 +0900 Subject: [PATCH 07/22] Consolidate validate indexed issue fixtures --- TESTING_GUIDE.md | 2 + .../QueryCommandRunnerValidateTests.cs | 282 ++++++------------ 2 files changed, 93 insertions(+), 191 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 674539b3c..6645214fc 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -229,6 +229,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. @@ -1198,6 +1199,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 も扱ってください。 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() { From d7500ae0193cbae4ead8ceb34a4d71cfc188d446 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 10:25:00 +0900 Subject: [PATCH 08/22] Consolidate archive validation fixtures --- TESTING_GUIDE.md | 2 + tests/CodeIndex.Tests/ProgramCliTests.cs | 216 +++++++++-------------- 2 files changed, 89 insertions(+), 129 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6645214fc..59a023a14 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -215,6 +215,7 @@ 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. 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. @@ -1185,6 +1186,7 @@ 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 を汚染しないようにします。 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 を固定します。 diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index a5e279275..7c7c16fad 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -696,32 +696,70 @@ public void ExportArchive_ManifestIncludesReadinessAndSummaryMetadata_Issue3549( } [ProductionRuntimeFact] - public void ImportArchive_RejectsManifestFileCountMismatch_Issue3549() + public void ImportArchive_RejectsCopiedManifestCountHashAndUserVersionMutations_Issue3549() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_manifest_count_mismatch"); + 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 importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); + 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]); - ReplaceManifestNumber(archivePath, "file_count", 999); - var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath, "--json"]); + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", pristineArchivePath, "--db", sourceDbPath]); 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)); + File.Copy(pristineArchivePath, countArchivePath); + File.Copy(pristineArchivePath, hashArchivePath); + File.Copy(pristineArchivePath, userVersionArchivePath); + + 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); } } @@ -791,40 +829,46 @@ public void ImportArchive_AcceptsOlderManifestWithoutSummaryMetadata_Issue3549() } [ProductionRuntimeFact] - public void ImportArchive_DryRunJsonValidatesWithoutReplacingDestination_Issue3550() + public void ImportArchive_DryRunAndCheckJsonSharePristineExport_Issues3550And4328() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_dry_run"); + 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"); - File.WriteAllText(destinationDbPath + "-wal", "existing wal"); - File.WriteAllText(destinationDbPath + "-shm", "existing shm"); + 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", destinationDbPath, "--prune-paths", "--no-backup", "--dry-run", "--json" + "import", archivePath, "--db", dryRunDbPath, "--prune-paths", "--no-backup", "--dry-run", "--json" + ]); + var (checkExit, checkStdout, checkStderr) = RunCliInSubprocess([ + "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 db", File.ReadAllText(destinationDbPath)); - Assert.Equal("existing wal", File.ReadAllText(destinationDbPath + "-wal")); - Assert.Equal("existing shm", File.ReadAllText(destinationDbPath + "-shm")); - - 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") + 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()!, @@ -837,42 +881,17 @@ public void ImportArchive_DryRunJsonValidatesWithoutReplacingDestination_Issue35 Assert.Equal("success", phases["sqlite_validate"]); Assert.Equal("success", phases["prune_paths"]); Assert.Equal("skipped", phases["replace_db"]); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [ProductionRuntimeFact] - public void ImportArchive_CheckJsonDistinguishesCheckMode_Issue4328() - { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_check_json"); - 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 (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - var (checkExit, checkStdout, checkStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--no-backup", "--check", "--json" - ]); - - Assert.True(exportExit == 0, exportStderr); 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 +934,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() { From 1a4b91ce1001faee174e74168faabb0b48806fb3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 10:37:40 +0900 Subject: [PATCH 09/22] Reuse archive export across restore contracts --- TESTING_GUIDE.md | 2 + tests/CodeIndex.Tests/ProgramCliTests.cs | 264 ++++++++++------------- 2 files changed, 114 insertions(+), 152 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 59a023a14..639f4a7ed 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -216,6 +216,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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. @@ -1187,6 +1188,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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 を固定します。 diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 7c7c16fad..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,35 +631,120 @@ 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); + + 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 (defaultImportExit, defaultImportStdout, defaultImportStderr) = RunCliInSubprocess([ + "import", pristineArchivePath, "--db", defaultImportDbPath + ]); + + 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 _)); + + File.Copy(pristineArchivePath, legacyArchivePath); + RemoveManifestProperties( + legacyArchivePath, + "file_count", + "chunk_count", + "symbol_count", + "reference_count", + "graph_ready", + "issues_ready", + "fold_ready", + "index_writer_version", + "indexed_head_branch", + "indexed_head_timestamp", + "codeindex_meta_schema_version", + "csharp_symbol_name_contract_version", + "sql_graph_contract_version", + "hotspot_family_version", + "unknown_extension_file_count", + "unknown_extension_files", + "unknown_extension_files_truncated", + "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(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 { @@ -763,71 +820,6 @@ public void ImportArchive_RejectsCopiedManifestCountHashAndUserVersionMutations_ } } - [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]); - RemoveManifestProperties( - archivePath, - "file_count", - "chunk_count", - "symbol_count", - "reference_count", - "graph_ready", - "issues_ready", - "fold_ready", - "index_writer_version", - "indexed_head_branch", - "indexed_head_timestamp", - "codeindex_meta_schema_version", - "csharp_symbol_name_contract_version", - "sql_graph_contract_version", - "hotspot_family_version", - "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"]); - - 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"]); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } - } - [ProductionRuntimeFact] public void ImportArchive_DryRunAndCheckJsonSharePristineExport_Issues3550And4328() { @@ -955,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() { From 0236f84b9830e1441e9e30c2bdb34142fd945801 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 12:53:36 +0900 Subject: [PATCH 10/22] Defer reference extractor performance warmup --- TESTING_GUIDE.md | 6 +- .../ReferenceExtractorCSharpTests.cs | 37 -------- ...eferenceExtractorPerformanceBudgetTests.cs | 85 +++++++++++++++++++ .../ReferenceExtractorTests.cs | 29 ------- .../ReferenceExtractorWarmup.cs | 12 +++ 5 files changed, 101 insertions(+), 68 deletions(-) create mode 100644 tests/CodeIndex.Tests/ReferenceExtractorPerformanceBudgetTests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 639f4a7ed..5a0fa1929 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -464,8 +464,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 @@ -1436,6 +1436,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 を重複させないでください。 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/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/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index bbb1fb0e9..26343fcc3 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -939,35 +939,6 @@ public void Extract_SwiftHighFanoutProperties_ReportsPropertyLookupBudgetDiagnos Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Kind == "reference_swift_property_line_name_budget_exceeded"); } -#if NET8_0 - [Fact] -#else - [Fact(Skip = PracticalBudgetTestTarget.SecondaryTargetSkipReason)] -#endif - public void Extract_CSharpLargePlainCallFile_CompletesWithinPracticalBudget() - { - 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."); - } - [Fact] public void Extract_GraphQL_MarkupSchemaReferences() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorWarmup.cs b/tests/CodeIndex.Tests/ReferenceExtractorWarmup.cs index 6171394e2..6138adcde 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorWarmup.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorWarmup.cs @@ -9,6 +9,9 @@ namespace CodeIndex.Tests; internal static class ReferenceExtractorWarmup { private static Process? persistentDiscoveryDescendant; + private static readonly Lazy performanceWarmup = new( + RunPerformanceWarmup, + LazyThreadSafetyMode.ExecutionAndPublication); [ModuleInitializer] internal static void WarmUp() @@ -46,10 +49,18 @@ internal static void WarmUp() persistentDescendantPidPath, persistentDiscoveryDescendant.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); } + } + internal static void EnsurePerformanceWarmup() + { if (!IsContinuousIntegration() || !IsNet8TestAssembly()) return; + _ = performanceWarmup.Value; + } + + private static bool RunPerformanceWarmup() + { // Practical budget tests measure steady-state extractor work; keep C# regex/JIT/tiered startup outside the guard. var builder = new StringBuilder(); builder.AppendLine("class Warmup {"); @@ -68,6 +79,7 @@ internal static void WarmUp() GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + return true; } private static bool IsContinuousIntegration() From d1de1821cf4c8a6324979a39fcfff842a33a1d3b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 13:12:34 +0900 Subject: [PATCH 11/22] Bound retained dotnet test output --- .github/scripts/run-dotnet-tests.ps1 | 44 +++++++++++++++++------- TESTING_GUIDE.md | 10 +++--- tests/CodeIndex.Tests/CiWorkflowTests.cs | 33 +++++++++++++++--- 3 files changed, 66 insertions(+), 21 deletions(-) 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/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5a0fa1929..b6be7007d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -46,13 +46,13 @@ 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. 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. @@ -685,6 +685,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. @@ -1014,13 +1015,13 @@ 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 で再インストールしないようにします。 ローカルの `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 を再利用してください。 @@ -1651,6 +1652,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へ戻します。 diff --git a/tests/CodeIndex.Tests/CiWorkflowTests.cs b/tests/CodeIndex.Tests/CiWorkflowTests.cs index fc6a2e01d..b64105051 100644 --- a/tests/CodeIndex.Tests/CiWorkflowTests.cs +++ b/tests/CodeIndex.Tests/CiWorkflowTests.cs @@ -124,10 +124,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 +140,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 +166,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"); @@ -517,7 +536,7 @@ public void DotnetWorkflow_UsesSdkCompatibleNuGetAudit() } [Fact] - public void TestingGuide_DocumentsSharedStateParallelismInventory() + public void TestingGuide_DocumentsSharedStateParallelismInventoryAndBoundedCiOutput() { var guide = RepositoryTestPaths.ReadText("TESTING_GUIDE.md"); @@ -533,6 +552,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行だけを保持", + "単一の構造化結果", "共有状態と並列実行の監査"); } From 44cf1b9c3d6713cb6cfc2908b0fb5ae513d3fe96 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 13:27:34 +0900 Subject: [PATCH 12/22] Scope license policy triggers to validated inputs --- .github/workflows/license-policy.yml | 12 -------- TESTING_GUIDE.md | 2 ++ tests/CodeIndex.Tests/LicensePolicyTests.cs | 34 ++++++++++++++++----- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.github/workflows/license-policy.yml b/.github/workflows/license-policy.yml index 47c0608df..933a232fe 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: diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index b6be7007d..602a494bd 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -374,6 +374,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. @@ -1347,6 +1348,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 を解決します。 diff --git a/tests/CodeIndex.Tests/LicensePolicyTests.cs b/tests/CodeIndex.Tests/LicensePolicyTests.cs index 65e7f32b2..d5f31f5e0 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,8 +163,9 @@ 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); @@ -211,6 +206,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) From 667cae06b49baad85ab55709b8096576e9260708 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 13:43:48 +0900 Subject: [PATCH 13/22] Consolidate Git metadata lifecycle fixtures --- TESTING_GUIDE.md | 6 +- tests/CodeIndex.Tests/GitHelperTests.cs | 240 +++++------------------- 2 files changed, 51 insertions(+), 195 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 602a494bd..6cf9cae92 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -796,7 +796,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. @@ -1764,7 +1765,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/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); From 4dd66466c9e1d2e1c4cb73ca3e9af89c844ee6db Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 4 Aug 2026 14:02:13 +0900 Subject: [PATCH 14/22] Consolidate production reference coverage fixtures --- TESTING_GUIDE.md | 2 + ...ferenceExtractorProductionCoverageTests.cs | 454 +++++------------- 2 files changed, 122 insertions(+), 334 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6cf9cae92..270764503 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -337,6 +337,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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 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 preserve cross-language filter 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. @@ -1311,6 +1312,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" generic switch-arm のguardとrelational predecessorも同様に1つのproduction-runtime fixtureを共有し、production `net8.0` targetだけで実行してください。 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でcross-language filter 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 を維持してください。 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", "